From 3181bef0057b1ae646ee527ca6da02e58cacdb0f Mon Sep 17 00:00:00 2001 From: aMytho <58316242+aMytho@users.noreply.github.com> Date: Fri, 2 Jan 2026 09:07:02 -0600 Subject: [PATCH 1/8] Remove submodules for sub projects --- .gitmodules | 6 ------ osse-broadcast | 1 - osse-web | 1 - 3 files changed, 8 deletions(-) delete mode 100644 .gitmodules delete mode 160000 osse-broadcast delete mode 160000 osse-web diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 642fa69..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "osse-web"] - path = osse-web - url = https://github.com/aMytho/osse-web -[submodule "osse-broadcast"] - path = osse-broadcast - url = https://github.com/aMytho/osse-broadcast diff --git a/osse-broadcast b/osse-broadcast deleted file mode 160000 index 2972c90..0000000 --- a/osse-broadcast +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2972c906b4f1610874935724802be306ab241642 diff --git a/osse-web b/osse-web deleted file mode 160000 index fca7a51..0000000 --- a/osse-web +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fca7a51606aa2815af744acd14d8607c8855b729 From 5505e13dfcb6906a85a407377105c9a8df812133 Mon Sep 17 00:00:00 2001 From: aMytho <58316242+aMytho@users.noreply.github.com> Date: Fri, 2 Jan 2026 09:25:19 -0600 Subject: [PATCH 2/8] Move projects into subfolders --- osse-broadcast/.dockerignore | 3 + osse-broadcast/.github/workflows/deploy.yml | 40 + osse-broadcast/.gitignore | 2 + osse-broadcast/Dockerfile | 12 + osse-broadcast/README.md | 7 + osse-broadcast/dev-run.sh | 10 + osse-broadcast/go.mod | 10 + osse-broadcast/go.sum | 8 + osse-broadcast/internal/config/config.go | 32 + osse-broadcast/internal/messages/messages.go | 166 + osse-broadcast/internal/redis/redis.go | 50 + osse-broadcast/internal/server/middleware.go | 30 + osse-broadcast/internal/server/server.go | 153 + osse-broadcast/main.go | 18 + osse-broadcast/prod-run.sh | 12 + .dockerignore => osse-core/.dockerignore | 0 .env.docker => osse-core/.env.docker | 0 .env.example => osse-core/.env.example | 0 .env.testing => osse-core/.env.testing | 0 .gitignore => osse-core/.gitignore | 0 README.md => osse-core/README.md | 0 _ide_helper.php => osse-core/_ide_helper.php | 0 .../app}/Enums/ScanDirStatus.php | 0 {app => osse-core/app}/Enums/ScanStatus.php | 0 .../app}/Events/ScanCancelled.php | 0 .../app}/Events/ScanCompleted.php | 0 {app => osse-core/app}/Events/ScanError.php | 0 {app => osse-core/app}/Events/ScanFailed.php | 0 .../app}/Events/ScanProgressed.php | 0 {app => osse-core/app}/Events/ScanStarted.php | 0 .../app}/Http/Controllers/AlbumController.php | 0 .../Http/Controllers/ArtistController.php | 0 .../app}/Http/Controllers/AuthController.php | 0 .../Http/Controllers/ConfigController.php | 0 .../app}/Http/Controllers/Controller.php | 0 .../Http/Controllers/CoverArtController.php | 0 .../Http/Controllers/PlaylistController.php | 0 .../app}/Http/Controllers/QueueController.php | 0 .../app}/Http/Controllers/ScanController.php | 0 .../app}/Http/Controllers/TrackController.php | 0 .../app}/Http/Middleware/HTTPCache.php | 0 .../Http/Middleware/RegistrationCheck.php | 0 .../Http/Requests/CreatePlaylistRequest.php | 0 .../Http/Requests/QueueActiveTrackRequest.php | 0 .../app}/Http/Requests/QueueRequest.php | 0 .../app}/Http/Requests/StoreConfigRequest.php | 0 .../app}/Http/Requests/TrackSearchRequest.php | 0 .../Http/Requests/UpdatePlaylistRequest.php | 0 .../app}/Http/Resources/AlbumResponse.php | 0 {app => osse-core/app}/Jobs/ScanMusic.php | 0 {app => osse-core/app}/Models/Album.php | 0 {app => osse-core/app}/Models/Artist.php | 0 {app => osse-core/app}/Models/CoverArt.php | 0 .../app}/Models/PlaybackSession.php | 0 {app => osse-core/app}/Models/Playlist.php | 0 .../app}/Models/ScanDirectory.php | 0 {app => osse-core/app}/Models/ScanError.php | 0 {app => osse-core/app}/Models/ScanJob.php | 0 {app => osse-core/app}/Models/Track.php | 0 {app => osse-core/app}/Models/User.php | 0 {app => osse-core/app}/Models/UserSetting.php | 0 .../app}/Providers/AppServiceProvider.php | 0 .../Services/MusicProcessor/ArtExtractor.php | 0 .../app}/Services/MusicProcessor/ArtFile.php | 0 .../Services/MusicProcessor/MusicMetadata.php | 0 .../MusicProcessor/MusicProcessor.php | 0 .../Services/MusicProcessor/MusicPruner.php | 0 artisan => osse-core/artisan | 0 {bootstrap => osse-core/bootstrap}/app.php | 0 .../bootstrap}/cache/.gitignore | 0 .../bootstrap}/providers.php | 0 composer.json => osse-core/composer.json | 0 composer.lock => osse-core/composer.lock | 0 {config => osse-core/config}/app.php | 0 {config => osse-core/config}/auth.php | 0 {config => osse-core/config}/broadcasting.php | 0 {config => osse-core/config}/cache.php | 0 {config => osse-core/config}/cors.php | 0 {config => osse-core/config}/database.php | 0 {config => osse-core/config}/filesystems.php | 0 {config => osse-core/config}/logging.php | 0 {config => osse-core/config}/mail.php | 0 {config => osse-core/config}/queue.php | 0 {config => osse-core/config}/sanctum.php | 0 {config => osse-core/config}/scan.php | 0 {config => osse-core/config}/services.php | 0 {config => osse-core/config}/session.php | 0 {database => osse-core/database}/.gitignore | 0 .../database}/factories/AlbumFactory.php | 0 .../database}/factories/ArtistFactory.php | 0 .../database}/factories/CoverArtFactory.php | 0 .../database}/factories/PlaylistFactory.php | 0 .../database}/factories/TrackFactory.php | 0 .../database}/factories/UserFactory.php | 0 .../0001_01_01_000000_create_users_table.php | 0 .../0001_01_01_000001_create_cache_table.php | 0 .../0001_01_01_000002_create_jobs_table.php | 0 ...15_create_personal_access_tokens_table.php | 0 .../migrations/2024_12_08_182623_init_app.php | 0 ...05_18_193956_create_queue_and_settings.php | 0 .../database}/seeders/DatabaseSeeder.php | 0 .../docker-compose.yml | 1 - {docker => osse-core/docker}/Caddyfile-http | 0 {docker => osse-core/docker}/Caddyfile-https | 0 {docker => osse-core/docker}/Dockerfile | 0 {docker => osse-core/docker}/entrypoint.sh | 0 {docker => osse-core/docker}/supervisor.conf | 0 .../package-lock.json | 0 package.json => osse-core/package.json | 0 phpunit.xml => osse-core/phpunit.xml | 0 .../postcss.config.js | 0 .../production-setup.sh | 0 {public => osse-core/public}/.htaccess | 0 {public => osse-core/public}/index.php | 0 {public => osse-core/public}/robots.txt | 0 .../resources}/css/app.css | 0 {resources => osse-core/resources}/js/app.js | 0 .../resources}/js/bootstrap.js | 0 .../resources}/views/welcome.blade.php | 0 {routes => osse-core/routes}/api.php | 0 {routes => osse-core/routes}/console.php | 0 {routes => osse-core/routes}/web.php | 0 {storage => osse-core/storage}/app/.gitignore | 0 .../storage}/app/private/.gitignore | 0 .../storage}/app/public/.gitignore | 0 .../storage}/framework/.gitignore | 0 .../storage}/framework/cache/.gitignore | 0 .../storage}/framework/cache/data/.gitignore | 0 .../storage}/framework/sessions/.gitignore | 0 .../storage}/framework/testing/.gitignore | 0 .../storage}/framework/views/.gitignore | 0 .../storage}/logs/.gitignore | 0 .../systemd}/Caddyfile-https-mtls | 0 .../systemd}/osse-broadcast.service | 0 .../systemd}/osse-frankenphp.service | 0 .../systemd}/osse-queue.service | 0 {systemd => osse-core/systemd}/osse.target | 0 .../tailwind.config.js | 0 .../Http/Controllers/ScanControllerTest.php | 0 .../Http/Middleware/RegistrationCheckTest.php | 0 .../tests}/Feature/Jobs/ScanMusicTest.php | 0 {tests => osse-core/tests}/TestCase.php | 0 .../tests}/Unit/ExampleTest.php | 0 .../tests}/files/covers/test_cover.mp3 | Bin .../tests}/files/covers/test_same_cover.mp3 | Bin .../tests}/files/has_metadata/track_one.mp3 | Bin .../tests}/files/has_metadata/track_two.mp3 | Bin .../tests}/files/invalid/bad_file.mp3 | 0 .../files/no_metadata/test_no_metadata.mp3 | Bin vite.config.js => osse-core/vite.config.js | 0 osse-web/.editorconfig | 16 + osse-web/.gitignore | 42 + osse-web/.postcssrc.json | 5 + osse-web/LICENSE | 661 ++ osse-web/README.md | 54 + osse-web/angular.json | 117 + osse-web/package.json | 43 + osse-web/pnpm-lock.yaml | 7294 +++++++++++++++++ osse-web/src/app/albums/albums.component.css | 6 + osse-web/src/app/albums/albums.component.html | 55 + osse-web/src/app/albums/albums.component.ts | 68 + osse-web/src/app/albums/view/album-filter.ts | 7 + .../app/albums/view/album-view.resolver.ts | 21 + .../src/app/albums/view/view.component.html | 71 + .../src/app/albums/view/view.component.ts | 201 + osse-web/src/app/app.component.css | 19 + osse-web/src/app/app.component.html | 17 + osse-web/src/app/app.component.spec.ts | 55 + osse-web/src/app/app.component.ts | 49 + osse-web/src/app/app.config.ts | 18 + osse-web/src/app/app.routes.ts | 67 + osse-web/src/app/home/home.component.html | 62 + osse-web/src/app/home/home.component.ts | 219 + .../src/app/home/track/track.component.html | 46 + .../src/app/home/track/track.component.ts | 55 + osse-web/src/app/locator.service.ts | 12 + osse-web/src/app/login/login.component.html | 72 + osse-web/src/app/login/login.component.ts | 94 + .../app/navigation/navigation.component.html | 46 + .../app/navigation/navigation.component.ts | 41 + .../playlist-view/editPlaylistModel.ts | 3 + .../playlist-add-tracks.component.html | 27 + .../playlist-add-tracks.component.ts | 143 + .../playlist-view.component.html | 75 + .../playlist-view/playlist-view.component.ts | 156 + .../src/app/playlist/playlist.component.html | 21 + .../src/app/playlist/playlist.component.ts | 52 + .../registration/registration.component.html | 69 + .../registration/registration.component.ts | 99 + .../app/settings/scan-progress.interface.ts | 7 + .../settings-logs.component.html | 14 + .../settings-logs/settings-logs.component.ts | 46 + .../settings-preferences/osse-config.ts | 4 + .../settings-preferences.component.html | 98 + .../settings-preferences.component.ts | 97 + .../settings/settings-scan-history/history.ts | 25 + .../settings-scan-history.component.html | 33 + .../settings-scan-history.component.ts | 49 + .../settings-scan.component.html | 129 + .../settings-scan/settings-scan.component.ts | 213 + .../src/app/settings/settings.component.html | 42 + .../src/app/settings/settings.component.ts | 42 + .../shared/player/buffer-update.interface.ts | 9 + .../clear-queue-controls.component.html | 6 + .../clear-queue-controls.component.ts | 20 + .../player/duration/duration.component.html | 1 + .../player/duration/duration.component.ts | 21 + .../jump-controls.component.html | 18 + .../jump-controls/jump-controls.component.ts | 26 + .../shared/player/media-session.service.ts | 71 + .../pan-controls/pan-controls.component.html | 12 + .../pan-controls/pan-controls.component.ts | 57 + .../app/shared/player/player.component.css | 717 ++ .../app/shared/player/player.component.html | 55 + .../src/app/shared/player/player.component.ts | 194 + .../src/app/shared/player/player.service.ts | 220 + osse-web/src/app/shared/player/point-state.ts | 4 + .../popover-controls.component.html | 40 + .../popover-controls.component.ts | 21 + .../shared/player/preload/preload.service.ts | 60 + .../speed-controls.component.html | 13 + .../speed-controls.component.ts | 55 + .../src/app/shared/player/state-change.ts | 4 + .../play-pause/play-pause.component.html | 3 + .../play-pause/play-pause.component.ts | 23 + .../track-controls.component.html | 9 + .../track-controls.component.ts | 26 + .../shared/player/track-position.interface.ts | 4 + .../src/app/shared/player/track-update.ts | 53 + .../visualizer/visualizer.component.html | 2 + .../player/visualizer/visualizer.component.ts | 85 + .../player/volume/volume.component.html | 8 + .../shared/player/volume/volume.component.ts | 90 + .../app/shared/player/web-audio.service.ts | 62 + .../src/app/shared/services/album/Album.ts | 52 + .../app/shared/services/album/osse-album.ts | 15 + .../src/app/shared/services/api.service.ts | 50 + .../services/artist/artist-store.service.ts | 39 + .../src/app/shared/services/artist/artist.ts | 13 + .../app/shared/services/artist/osse-artist.ts | 4 + .../app/shared/services/auth/auth.guard.ts | 20 + .../shared/services/auth/auth.interface.ts | 16 + .../app/shared/services/auth/auth.service.ts | 112 + .../shared/services/config/config.service.ts | 73 + .../src/app/shared/services/config/config.ts | 30 + .../shared/services/echo/channels/index.ts | 15 + .../app/shared/services/echo/channels/scan.ts | 77 + .../app/shared/services/echo/echo.service.ts | 89 + .../services/network/network.service.ts | 20 + .../app/shared/services/playlist/Playlist.ts | 39 + .../shared/services/playlist/osse-playlist.ts | 9 + .../services/playlist/playlist.service.ts | 57 + .../app/shared/services/track/osse-track.ts | 20 + .../services/track/queue-sync.service.ts | 112 + .../app/shared/services/track/repeat.enum.ts | 8 + .../shared/services/track/track.service.ts | 289 + .../src/app/shared/services/track/track.ts | 171 + .../app/shared/ui/background-image.service.ts | 20 + .../shared/ui/header/header.component.html | 13 + .../app/shared/ui/header/header.component.ts | 14 + .../app/shared/ui/icon/icon.component.html | 10 + .../src/app/shared/ui/icon/icon.component.ts | 14 + .../shared/ui/loading/loading.component.css | 67 + .../shared/ui/loading/loading.component.html | 9 + .../shared/ui/loading/loading.component.ts | 59 + .../app/shared/ui/loading/loading.service.ts | 31 + .../app/shared/ui/modal/modal.component.html | 8 + .../app/shared/ui/modal/modal.component.ts | 57 + .../src/app/shared/ui/modal/modal.service.ts | 28 + .../src/app/shared/ui/modal/modal.styles.css | 4 + ...multiple-tracks-to-playlist.component.html | 16 + ...d-multiple-tracks-to-playlist.component.ts | 38 + .../add-to-playlist-factory.component.html | 11 + .../add-to-playlist-factory.component.ts | 32 + .../album-art-fullscreen.component.html | 7 + .../album-art-fullscreen.component.ts | 22 + ...ate-new-playlist-for-tracks.component.html | 14 + ...reate-new-playlist-for-tracks.component.ts | 52 + .../player-settings.component.html | 20 + .../player-settings.component.ts | 39 + .../track-info/track-info.component.html | 20 + .../modals/track-info/track-info.component.ts | 18 + .../matrix-item/matrix-item.component.html | 30 + .../matrix-item/matrix-item.component.ts | 45 + .../app/shared/ui/track-matrix/track-info.ts | 22 + .../track-matrix/track-matrix-click.enum.ts | 4 + .../ui/track-matrix/track-matrix-mode.enum.ts | 4 + .../track-matrix/track-matrix.component.html | 9 + .../ui/track-matrix/track-matrix.component.ts | 65 + osse-web/src/app/shared/util/fetcher.ts | 63 + osse-web/src/app/shared/util/time.ts | 19 + .../toast-container.component.css | 14 + .../toast-container.component.html | 18 + .../toast-container.component.ts | 18 + .../src/app/toast-container/toast.service.ts | 51 + .../app/track-list/track-list.component.html | 38 + .../app/track-list/track-list.component.ts | 174 + osse-web/src/assets/.gitkeep | 0 .../assets/icons/android-chrome-192x192.png | Bin 0 -> 16760 bytes .../assets/icons/android-chrome-512x512.png | Bin 0 -> 59282 bytes .../src/assets/icons/apple-touch-icon.png | Bin 0 -> 15074 bytes osse-web/src/assets/icons/favicon-16x16.png | Bin 0 -> 733 bytes osse-web/src/assets/icons/favicon-32x32.png | Bin 0 -> 1828 bytes osse-web/src/assets/icons/favicon.ico | Bin 0 -> 15406 bytes osse-web/src/assets/icons/site.webmanifest | 1 + osse-web/src/assets/img/osse.webp | Bin 0 -> 21282 bytes osse-web/src/environments/environment.prod.ts | 9 + osse-web/src/environments/environment.ts | 9 + osse-web/src/index.html | 19 + osse-web/src/main.ts | 8 + osse-web/src/styles.css | 70 + osse-web/tsconfig.app.json | 14 + osse-web/tsconfig.json | 28 + osse-web/tsconfig.spec.json | 14 + 314 files changed, 16218 insertions(+), 1 deletion(-) create mode 100644 osse-broadcast/.dockerignore create mode 100644 osse-broadcast/.github/workflows/deploy.yml create mode 100644 osse-broadcast/.gitignore create mode 100644 osse-broadcast/Dockerfile create mode 100644 osse-broadcast/README.md create mode 100755 osse-broadcast/dev-run.sh create mode 100644 osse-broadcast/go.mod create mode 100644 osse-broadcast/go.sum create mode 100644 osse-broadcast/internal/config/config.go create mode 100644 osse-broadcast/internal/messages/messages.go create mode 100644 osse-broadcast/internal/redis/redis.go create mode 100644 osse-broadcast/internal/server/middleware.go create mode 100644 osse-broadcast/internal/server/server.go create mode 100644 osse-broadcast/main.go create mode 100644 osse-broadcast/prod-run.sh rename .dockerignore => osse-core/.dockerignore (100%) rename .env.docker => osse-core/.env.docker (100%) rename .env.example => osse-core/.env.example (100%) rename .env.testing => osse-core/.env.testing (100%) rename .gitignore => osse-core/.gitignore (100%) rename README.md => osse-core/README.md (100%) rename _ide_helper.php => osse-core/_ide_helper.php (100%) rename {app => osse-core/app}/Enums/ScanDirStatus.php (100%) rename {app => osse-core/app}/Enums/ScanStatus.php (100%) rename {app => osse-core/app}/Events/ScanCancelled.php (100%) rename {app => osse-core/app}/Events/ScanCompleted.php (100%) rename {app => osse-core/app}/Events/ScanError.php (100%) rename {app => osse-core/app}/Events/ScanFailed.php (100%) rename {app => osse-core/app}/Events/ScanProgressed.php (100%) rename {app => osse-core/app}/Events/ScanStarted.php (100%) rename {app => osse-core/app}/Http/Controllers/AlbumController.php (100%) rename {app => osse-core/app}/Http/Controllers/ArtistController.php (100%) rename {app => osse-core/app}/Http/Controllers/AuthController.php (100%) rename {app => osse-core/app}/Http/Controllers/ConfigController.php (100%) rename {app => osse-core/app}/Http/Controllers/Controller.php (100%) rename {app => osse-core/app}/Http/Controllers/CoverArtController.php (100%) rename {app => osse-core/app}/Http/Controllers/PlaylistController.php (100%) rename {app => osse-core/app}/Http/Controllers/QueueController.php (100%) rename {app => osse-core/app}/Http/Controllers/ScanController.php (100%) rename {app => osse-core/app}/Http/Controllers/TrackController.php (100%) rename {app => osse-core/app}/Http/Middleware/HTTPCache.php (100%) rename {app => osse-core/app}/Http/Middleware/RegistrationCheck.php (100%) rename {app => osse-core/app}/Http/Requests/CreatePlaylistRequest.php (100%) rename {app => osse-core/app}/Http/Requests/QueueActiveTrackRequest.php (100%) rename {app => osse-core/app}/Http/Requests/QueueRequest.php (100%) rename {app => osse-core/app}/Http/Requests/StoreConfigRequest.php (100%) rename {app => osse-core/app}/Http/Requests/TrackSearchRequest.php (100%) rename {app => osse-core/app}/Http/Requests/UpdatePlaylistRequest.php (100%) rename {app => osse-core/app}/Http/Resources/AlbumResponse.php (100%) rename {app => osse-core/app}/Jobs/ScanMusic.php (100%) rename {app => osse-core/app}/Models/Album.php (100%) rename {app => osse-core/app}/Models/Artist.php (100%) rename {app => osse-core/app}/Models/CoverArt.php (100%) rename {app => osse-core/app}/Models/PlaybackSession.php (100%) rename {app => osse-core/app}/Models/Playlist.php (100%) rename {app => osse-core/app}/Models/ScanDirectory.php (100%) rename {app => osse-core/app}/Models/ScanError.php (100%) rename {app => osse-core/app}/Models/ScanJob.php (100%) rename {app => osse-core/app}/Models/Track.php (100%) rename {app => osse-core/app}/Models/User.php (100%) rename {app => osse-core/app}/Models/UserSetting.php (100%) rename {app => osse-core/app}/Providers/AppServiceProvider.php (100%) rename {app => osse-core/app}/Services/MusicProcessor/ArtExtractor.php (100%) rename {app => osse-core/app}/Services/MusicProcessor/ArtFile.php (100%) rename {app => osse-core/app}/Services/MusicProcessor/MusicMetadata.php (100%) rename {app => osse-core/app}/Services/MusicProcessor/MusicProcessor.php (100%) rename {app => osse-core/app}/Services/MusicProcessor/MusicPruner.php (100%) rename artisan => osse-core/artisan (100%) rename {bootstrap => osse-core/bootstrap}/app.php (100%) rename {bootstrap => osse-core/bootstrap}/cache/.gitignore (100%) rename {bootstrap => osse-core/bootstrap}/providers.php (100%) rename composer.json => osse-core/composer.json (100%) rename composer.lock => osse-core/composer.lock (100%) rename {config => osse-core/config}/app.php (100%) rename {config => osse-core/config}/auth.php (100%) rename {config => osse-core/config}/broadcasting.php (100%) rename {config => osse-core/config}/cache.php (100%) rename {config => osse-core/config}/cors.php (100%) rename {config => osse-core/config}/database.php (100%) rename {config => osse-core/config}/filesystems.php (100%) rename {config => osse-core/config}/logging.php (100%) rename {config => osse-core/config}/mail.php (100%) rename {config => osse-core/config}/queue.php (100%) rename {config => osse-core/config}/sanctum.php (100%) rename {config => osse-core/config}/scan.php (100%) rename {config => osse-core/config}/services.php (100%) rename {config => osse-core/config}/session.php (100%) rename {database => osse-core/database}/.gitignore (100%) rename {database => osse-core/database}/factories/AlbumFactory.php (100%) rename {database => osse-core/database}/factories/ArtistFactory.php (100%) rename {database => osse-core/database}/factories/CoverArtFactory.php (100%) rename {database => osse-core/database}/factories/PlaylistFactory.php (100%) rename {database => osse-core/database}/factories/TrackFactory.php (100%) rename {database => osse-core/database}/factories/UserFactory.php (100%) rename {database => osse-core/database}/migrations/0001_01_01_000000_create_users_table.php (100%) rename {database => osse-core/database}/migrations/0001_01_01_000001_create_cache_table.php (100%) rename {database => osse-core/database}/migrations/0001_01_01_000002_create_jobs_table.php (100%) rename {database => osse-core/database}/migrations/2024_12_08_181615_create_personal_access_tokens_table.php (100%) rename {database => osse-core/database}/migrations/2024_12_08_182623_init_app.php (100%) rename {database => osse-core/database}/migrations/2025_05_18_193956_create_queue_and_settings.php (100%) rename {database => osse-core/database}/seeders/DatabaseSeeder.php (100%) rename docker-compose.yml => osse-core/docker-compose.yml (99%) rename {docker => osse-core/docker}/Caddyfile-http (100%) rename {docker => osse-core/docker}/Caddyfile-https (100%) rename {docker => osse-core/docker}/Dockerfile (100%) rename {docker => osse-core/docker}/entrypoint.sh (100%) rename {docker => osse-core/docker}/supervisor.conf (100%) rename package-lock.json => osse-core/package-lock.json (100%) rename package.json => osse-core/package.json (100%) rename phpunit.xml => osse-core/phpunit.xml (100%) rename postcss.config.js => osse-core/postcss.config.js (100%) rename production-setup.sh => osse-core/production-setup.sh (100%) rename {public => osse-core/public}/.htaccess (100%) rename {public => osse-core/public}/index.php (100%) rename {public => osse-core/public}/robots.txt (100%) rename {resources => osse-core/resources}/css/app.css (100%) rename {resources => osse-core/resources}/js/app.js (100%) rename {resources => osse-core/resources}/js/bootstrap.js (100%) rename {resources => osse-core/resources}/views/welcome.blade.php (100%) rename {routes => osse-core/routes}/api.php (100%) rename {routes => osse-core/routes}/console.php (100%) rename {routes => osse-core/routes}/web.php (100%) rename {storage => osse-core/storage}/app/.gitignore (100%) rename {storage => osse-core/storage}/app/private/.gitignore (100%) rename {storage => osse-core/storage}/app/public/.gitignore (100%) rename {storage => osse-core/storage}/framework/.gitignore (100%) rename {storage => osse-core/storage}/framework/cache/.gitignore (100%) rename {storage => osse-core/storage}/framework/cache/data/.gitignore (100%) rename {storage => osse-core/storage}/framework/sessions/.gitignore (100%) rename {storage => osse-core/storage}/framework/testing/.gitignore (100%) rename {storage => osse-core/storage}/framework/views/.gitignore (100%) rename {storage => osse-core/storage}/logs/.gitignore (100%) rename {systemd => osse-core/systemd}/Caddyfile-https-mtls (100%) rename {systemd => osse-core/systemd}/osse-broadcast.service (100%) rename {systemd => osse-core/systemd}/osse-frankenphp.service (100%) rename {systemd => osse-core/systemd}/osse-queue.service (100%) rename {systemd => osse-core/systemd}/osse.target (100%) rename tailwind.config.js => osse-core/tailwind.config.js (100%) rename {tests => osse-core/tests}/Feature/Http/Controllers/ScanControllerTest.php (100%) rename {tests => osse-core/tests}/Feature/Http/Middleware/RegistrationCheckTest.php (100%) rename {tests => osse-core/tests}/Feature/Jobs/ScanMusicTest.php (100%) rename {tests => osse-core/tests}/TestCase.php (100%) rename {tests => osse-core/tests}/Unit/ExampleTest.php (100%) rename {tests => osse-core/tests}/files/covers/test_cover.mp3 (100%) rename {tests => osse-core/tests}/files/covers/test_same_cover.mp3 (100%) rename {tests => osse-core/tests}/files/has_metadata/track_one.mp3 (100%) rename {tests => osse-core/tests}/files/has_metadata/track_two.mp3 (100%) rename {tests => osse-core/tests}/files/invalid/bad_file.mp3 (100%) rename {tests => osse-core/tests}/files/no_metadata/test_no_metadata.mp3 (100%) rename vite.config.js => osse-core/vite.config.js (100%) create mode 100644 osse-web/.editorconfig create mode 100644 osse-web/.gitignore create mode 100644 osse-web/.postcssrc.json create mode 100644 osse-web/LICENSE create mode 100644 osse-web/README.md create mode 100644 osse-web/angular.json create mode 100644 osse-web/package.json create mode 100644 osse-web/pnpm-lock.yaml create mode 100644 osse-web/src/app/albums/albums.component.css create mode 100644 osse-web/src/app/albums/albums.component.html create mode 100644 osse-web/src/app/albums/albums.component.ts create mode 100644 osse-web/src/app/albums/view/album-filter.ts create mode 100644 osse-web/src/app/albums/view/album-view.resolver.ts create mode 100644 osse-web/src/app/albums/view/view.component.html create mode 100644 osse-web/src/app/albums/view/view.component.ts create mode 100644 osse-web/src/app/app.component.css create mode 100644 osse-web/src/app/app.component.html create mode 100644 osse-web/src/app/app.component.spec.ts create mode 100644 osse-web/src/app/app.component.ts create mode 100644 osse-web/src/app/app.config.ts create mode 100644 osse-web/src/app/app.routes.ts create mode 100644 osse-web/src/app/home/home.component.html create mode 100644 osse-web/src/app/home/home.component.ts create mode 100644 osse-web/src/app/home/track/track.component.html create mode 100644 osse-web/src/app/home/track/track.component.ts create mode 100644 osse-web/src/app/locator.service.ts create mode 100644 osse-web/src/app/login/login.component.html create mode 100644 osse-web/src/app/login/login.component.ts create mode 100644 osse-web/src/app/navigation/navigation.component.html create mode 100644 osse-web/src/app/navigation/navigation.component.ts create mode 100644 osse-web/src/app/playlist/playlist-view/editPlaylistModel.ts create mode 100644 osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.html create mode 100644 osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.ts create mode 100644 osse-web/src/app/playlist/playlist-view/playlist-view.component.html create mode 100644 osse-web/src/app/playlist/playlist-view/playlist-view.component.ts create mode 100644 osse-web/src/app/playlist/playlist.component.html create mode 100644 osse-web/src/app/playlist/playlist.component.ts create mode 100644 osse-web/src/app/registration/registration.component.html create mode 100644 osse-web/src/app/registration/registration.component.ts create mode 100644 osse-web/src/app/settings/scan-progress.interface.ts create mode 100644 osse-web/src/app/settings/settings-logs/settings-logs.component.html create mode 100644 osse-web/src/app/settings/settings-logs/settings-logs.component.ts create mode 100644 osse-web/src/app/settings/settings-preferences/osse-config.ts create mode 100644 osse-web/src/app/settings/settings-preferences/settings-preferences.component.html create mode 100644 osse-web/src/app/settings/settings-preferences/settings-preferences.component.ts create mode 100644 osse-web/src/app/settings/settings-scan-history/history.ts create mode 100644 osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.html create mode 100644 osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.ts create mode 100644 osse-web/src/app/settings/settings-scan/settings-scan.component.html create mode 100644 osse-web/src/app/settings/settings-scan/settings-scan.component.ts create mode 100644 osse-web/src/app/settings/settings.component.html create mode 100644 osse-web/src/app/settings/settings.component.ts create mode 100644 osse-web/src/app/shared/player/buffer-update.interface.ts create mode 100644 osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.html create mode 100644 osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.ts create mode 100644 osse-web/src/app/shared/player/duration/duration.component.html create mode 100644 osse-web/src/app/shared/player/duration/duration.component.ts create mode 100644 osse-web/src/app/shared/player/jump-controls/jump-controls.component.html create mode 100644 osse-web/src/app/shared/player/jump-controls/jump-controls.component.ts create mode 100644 osse-web/src/app/shared/player/media-session.service.ts create mode 100644 osse-web/src/app/shared/player/pan-controls/pan-controls.component.html create mode 100644 osse-web/src/app/shared/player/pan-controls/pan-controls.component.ts create mode 100644 osse-web/src/app/shared/player/player.component.css create mode 100644 osse-web/src/app/shared/player/player.component.html create mode 100644 osse-web/src/app/shared/player/player.component.ts create mode 100644 osse-web/src/app/shared/player/player.service.ts create mode 100644 osse-web/src/app/shared/player/point-state.ts create mode 100644 osse-web/src/app/shared/player/popover-controls/popover-controls.component.html create mode 100644 osse-web/src/app/shared/player/popover-controls/popover-controls.component.ts create mode 100644 osse-web/src/app/shared/player/preload/preload.service.ts create mode 100644 osse-web/src/app/shared/player/speed-controls/speed-controls.component.html create mode 100644 osse-web/src/app/shared/player/speed-controls/speed-controls.component.ts create mode 100644 osse-web/src/app/shared/player/state-change.ts create mode 100644 osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.html create mode 100644 osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.ts create mode 100644 osse-web/src/app/shared/player/track-controls/track-controls.component.html create mode 100644 osse-web/src/app/shared/player/track-controls/track-controls.component.ts create mode 100644 osse-web/src/app/shared/player/track-position.interface.ts create mode 100644 osse-web/src/app/shared/player/track-update.ts create mode 100644 osse-web/src/app/shared/player/visualizer/visualizer.component.html create mode 100644 osse-web/src/app/shared/player/visualizer/visualizer.component.ts create mode 100644 osse-web/src/app/shared/player/volume/volume.component.html create mode 100644 osse-web/src/app/shared/player/volume/volume.component.ts create mode 100644 osse-web/src/app/shared/player/web-audio.service.ts create mode 100644 osse-web/src/app/shared/services/album/Album.ts create mode 100644 osse-web/src/app/shared/services/album/osse-album.ts create mode 100644 osse-web/src/app/shared/services/api.service.ts create mode 100644 osse-web/src/app/shared/services/artist/artist-store.service.ts create mode 100644 osse-web/src/app/shared/services/artist/artist.ts create mode 100644 osse-web/src/app/shared/services/artist/osse-artist.ts create mode 100644 osse-web/src/app/shared/services/auth/auth.guard.ts create mode 100644 osse-web/src/app/shared/services/auth/auth.interface.ts create mode 100644 osse-web/src/app/shared/services/auth/auth.service.ts create mode 100644 osse-web/src/app/shared/services/config/config.service.ts create mode 100644 osse-web/src/app/shared/services/config/config.ts create mode 100644 osse-web/src/app/shared/services/echo/channels/index.ts create mode 100644 osse-web/src/app/shared/services/echo/channels/scan.ts create mode 100644 osse-web/src/app/shared/services/echo/echo.service.ts create mode 100644 osse-web/src/app/shared/services/network/network.service.ts create mode 100644 osse-web/src/app/shared/services/playlist/Playlist.ts create mode 100644 osse-web/src/app/shared/services/playlist/osse-playlist.ts create mode 100644 osse-web/src/app/shared/services/playlist/playlist.service.ts create mode 100644 osse-web/src/app/shared/services/track/osse-track.ts create mode 100644 osse-web/src/app/shared/services/track/queue-sync.service.ts create mode 100644 osse-web/src/app/shared/services/track/repeat.enum.ts create mode 100644 osse-web/src/app/shared/services/track/track.service.ts create mode 100644 osse-web/src/app/shared/services/track/track.ts create mode 100644 osse-web/src/app/shared/ui/background-image.service.ts create mode 100644 osse-web/src/app/shared/ui/header/header.component.html create mode 100644 osse-web/src/app/shared/ui/header/header.component.ts create mode 100644 osse-web/src/app/shared/ui/icon/icon.component.html create mode 100644 osse-web/src/app/shared/ui/icon/icon.component.ts create mode 100644 osse-web/src/app/shared/ui/loading/loading.component.css create mode 100644 osse-web/src/app/shared/ui/loading/loading.component.html create mode 100644 osse-web/src/app/shared/ui/loading/loading.component.ts create mode 100644 osse-web/src/app/shared/ui/loading/loading.service.ts create mode 100644 osse-web/src/app/shared/ui/modal/modal.component.html create mode 100644 osse-web/src/app/shared/ui/modal/modal.component.ts create mode 100644 osse-web/src/app/shared/ui/modal/modal.service.ts create mode 100644 osse-web/src/app/shared/ui/modal/modal.styles.css create mode 100644 osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.html create mode 100644 osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.ts create mode 100644 osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.html create mode 100644 osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.ts create mode 100644 osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.html create mode 100644 osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.ts create mode 100644 osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.html create mode 100644 osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.ts create mode 100644 osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.html create mode 100644 osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.ts create mode 100644 osse-web/src/app/shared/ui/modals/track-info/track-info.component.html create mode 100644 osse-web/src/app/shared/ui/modals/track-info/track-info.component.ts create mode 100644 osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.html create mode 100644 osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.ts create mode 100644 osse-web/src/app/shared/ui/track-matrix/track-info.ts create mode 100644 osse-web/src/app/shared/ui/track-matrix/track-matrix-click.enum.ts create mode 100644 osse-web/src/app/shared/ui/track-matrix/track-matrix-mode.enum.ts create mode 100644 osse-web/src/app/shared/ui/track-matrix/track-matrix.component.html create mode 100644 osse-web/src/app/shared/ui/track-matrix/track-matrix.component.ts create mode 100644 osse-web/src/app/shared/util/fetcher.ts create mode 100644 osse-web/src/app/shared/util/time.ts create mode 100644 osse-web/src/app/toast-container/toast-container.component.css create mode 100644 osse-web/src/app/toast-container/toast-container.component.html create mode 100644 osse-web/src/app/toast-container/toast-container.component.ts create mode 100644 osse-web/src/app/toast-container/toast.service.ts create mode 100644 osse-web/src/app/track-list/track-list.component.html create mode 100644 osse-web/src/app/track-list/track-list.component.ts create mode 100644 osse-web/src/assets/.gitkeep create mode 100644 osse-web/src/assets/icons/android-chrome-192x192.png create mode 100644 osse-web/src/assets/icons/android-chrome-512x512.png create mode 100644 osse-web/src/assets/icons/apple-touch-icon.png create mode 100644 osse-web/src/assets/icons/favicon-16x16.png create mode 100644 osse-web/src/assets/icons/favicon-32x32.png create mode 100644 osse-web/src/assets/icons/favicon.ico create mode 100644 osse-web/src/assets/icons/site.webmanifest create mode 100644 osse-web/src/assets/img/osse.webp create mode 100644 osse-web/src/environments/environment.prod.ts create mode 100644 osse-web/src/environments/environment.ts create mode 100644 osse-web/src/index.html create mode 100644 osse-web/src/main.ts create mode 100644 osse-web/src/styles.css create mode 100644 osse-web/tsconfig.app.json create mode 100644 osse-web/tsconfig.json create mode 100644 osse-web/tsconfig.spec.json diff --git a/osse-broadcast/.dockerignore b/osse-broadcast/.dockerignore new file mode 100644 index 0000000..94c3d6b --- /dev/null +++ b/osse-broadcast/.dockerignore @@ -0,0 +1,3 @@ +.git +.github + diff --git a/osse-broadcast/.github/workflows/deploy.yml b/osse-broadcast/.github/workflows/deploy.yml new file mode 100644 index 0000000..751df2d --- /dev/null +++ b/osse-broadcast/.github/workflows/deploy.yml @@ -0,0 +1,40 @@ +name: Build Go Project + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + name: Build Go Binary + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install Dependencies + run: go mod tidy + + - name: Build for Linux (x86_64) + run: | + GOOS=linux GOARCH=amd64 go build -o bin/osse-broadcast-linux-amd64 + + - name: Build for Linux (ARM64) + run: | + GOOS=linux GOARCH=arm64 go build -o bin/osse-broadcast-linux-arm64 + + - name: Upload Binaries + uses: actions/upload-artifact@v4 + with: + name: go-binaries + path: bin/ diff --git a/osse-broadcast/.gitignore b/osse-broadcast/.gitignore new file mode 100644 index 0000000..fedaa2b --- /dev/null +++ b/osse-broadcast/.gitignore @@ -0,0 +1,2 @@ +/target +.env diff --git a/osse-broadcast/Dockerfile b/osse-broadcast/Dockerfile new file mode 100644 index 0000000..81e474f --- /dev/null +++ b/osse-broadcast/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.24-alpine + +WORKDIR /usr/src/app + +# pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN go build -v -o /usr/local/bin/osse-broadcast . + +ENTRYPOINT [ "sh", "prod-run.sh" ] diff --git a/osse-broadcast/README.md b/osse-broadcast/README.md new file mode 100644 index 0000000..f5a9809 --- /dev/null +++ b/osse-broadcast/README.md @@ -0,0 +1,7 @@ +# Osse-Broadcast + +This is the SSE server for the Osse music server. + +Osse is written in PHP. This works great for requests, but not so great for long lived connections. + +This server is in go, highly concurrent, and much lighter than the Laravel Reverb implementation. diff --git a/osse-broadcast/dev-run.sh b/osse-broadcast/dev-run.sh new file mode 100755 index 0000000..471078c --- /dev/null +++ b/osse-broadcast/dev-run.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Dev script to run osse-broadcast with the envs. Change these in development ONLY. +# If you are running this in production (as a user), read the instructions. You shouldn't be here :) + +export OSSE_BROADCAST_URL="localhost:9003" +export OSSE_REDIS_HOST="localhost:6379" +export OSSE_ALLOWED_ORIGIN="localhost:4200" + +go run . diff --git a/osse-broadcast/go.mod b/osse-broadcast/go.mod new file mode 100644 index 0000000..358df4a --- /dev/null +++ b/osse-broadcast/go.mod @@ -0,0 +1,10 @@ +module osse-broadcast + +go 1.24.1 + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/redis/go-redis/v9 v9.7.3 // indirect + github.com/tmaxmax/go-sse v0.10.0 // indirect +) diff --git a/osse-broadcast/go.sum b/osse-broadcast/go.sum new file mode 100644 index 0000000..35a6576 --- /dev/null +++ b/osse-broadcast/go.sum @@ -0,0 +1,8 @@ +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/tmaxmax/go-sse v0.10.0 h1:j9F93WB4Hxt8wUf6oGffMm4dutALvUPoDDxfuDQOSqA= +github.com/tmaxmax/go-sse v0.10.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8= diff --git a/osse-broadcast/internal/config/config.go b/osse-broadcast/internal/config/config.go new file mode 100644 index 0000000..284c0e7 --- /dev/null +++ b/osse-broadcast/internal/config/config.go @@ -0,0 +1,32 @@ +package config + +import ( + "log" + "os" +) + +type OsseConfig struct { + HttpHost string + RedisHost string + OsseClientOrigin string +} + +func GetOsseConfig() OsseConfig { + httpHost := getEnvVar("OSSE_BROADCAST_URL") + redisHost := getEnvVar("OSSE_REDIS_HOST") + osseClientOrigin := getEnvVar("OSSE_ALLOWED_ORIGIN") + + return OsseConfig{httpHost, redisHost, osseClientOrigin} +} + +func getEnvVar(key string) string { + result, varExists := os.LookupEnv(key) + + if !varExists { + log.Println("The environment variable " + key + " was not set. Please set this var in the osse config file.") + log.Println("Osse Broadcast is shutting down!") + os.Exit(1) + } + + return result +} diff --git a/osse-broadcast/internal/messages/messages.go b/osse-broadcast/internal/messages/messages.go new file mode 100644 index 0000000..fa4f4d6 --- /dev/null +++ b/osse-broadcast/internal/messages/messages.go @@ -0,0 +1,166 @@ +package messages + +import ( + "encoding/json" + "fmt" + "log" +) + +const ( + SCANSTARTED string = "ScanStarted" + SCANCOMPLETE string = "ScanCompleted" + SCANPROGRESSED string = "ScanProgressed" + SCANERROR string = "ScanError" // Problem, scan not stopped. + SCANFAILED string = "ScanFailed" // Scan stopped. + SCANCANCELLED string = "ScanCancelled" +) + +var AllTopics = []string{ + SCANSTARTED, + SCANCOMPLETE, + SCANPROGRESSED, + SCANERROR, + SCANFAILED, + SCANCANCELLED, +} + +// The raw message sent from redis. +type BaseEvent struct { + Event string `json:"event"` + Data json.RawMessage `json:"data"` +} + +type OsseEvent interface { + GetType() string +} + +// The individual message types from Osse + +type ScanStarted struct { + Directories []ScanDirectory `json:"directories"` +} + +func (s ScanStarted) GetType() string { + return SCANSTARTED +} + +type ScanDirectory struct { + ID uint `json:"id"` + ScanJobID uint `json:"scanJobID"` + Path string `json:"path"` + Status string `json:"status"` + FilesScanned uint `json:"filesScanned"` + FilesSkipped uint `json:"filesSkipped"` + StartedAt *string `json:"startedAt"` + FinishedAt *string `json:"finishedAt"` +} + +type ScanProgressed struct { + DirectoryID uint `json:"directoryID"` + DirectoryName string `json:"directoryName"` + FilesScanned int `json:"filesScanned"` + FilesSkipped int `json:"filesSkipped"` + Status string `json:"status"` +} + +func (s ScanProgressed) GetType() string { + return SCANPROGRESSED +} + +type ScanCompleted struct { + DirectoryCount int `json:"directoryCount"` +} + +func (s ScanCompleted) GetType() string { + return SCANCOMPLETE +} + +type ScanError struct { + Message string `json:"message"` +} + +func (s ScanError) GetType() string { + return SCANERROR +} + +type ScanFailed struct { + Reason string `json:"message"` +} + +func (s ScanFailed) GetType() string { + return SCANFAILED +} + +type ScanCancelled struct { + DirectoriesScannedBeforeCancellation int `json:"directoriesScannedBeforeCancellation"` +} + +func (s ScanCancelled) GetType() string { + return SCANCANCELLED +} + +func GetEventFromMessage(message string) (OsseEvent, error) { + var base BaseEvent + err := json.Unmarshal([]byte(message), &base) + if err != nil { + log.Println("Error parsing event: ", err) + return nil, err + } + + // Now that its valid json, we determine the event type + switch base.Event { + case "App\\Events\\ScanStarted": + var data ScanStarted + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanProgressed": + var data ScanProgressed + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanCompleted": + var data ScanCompleted + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanError": + var data ScanError + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanFailed": + var data ScanFailed + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanCancelled": + var data ScanCancelled + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + } + + return nil, fmt.Errorf("Unknown event: %s", base.Event) +} + +func GetJsonOfEvent(event OsseEvent) (string, error) { + jsonData, err := json.Marshal(event) + if err != nil { + log.Println("Error with converting osse event to json.") + return "", err + } + + return string(jsonData), nil +} diff --git a/osse-broadcast/internal/redis/redis.go b/osse-broadcast/internal/redis/redis.go new file mode 100644 index 0000000..393e172 --- /dev/null +++ b/osse-broadcast/internal/redis/redis.go @@ -0,0 +1,50 @@ +package redis + +import ( + "context" + "log" + "osse-broadcast/internal/messages" + "time" + + "github.com/redis/go-redis/v9" +) + +var rdb *redis.Client + +func Connect(host string, channel chan messages.OsseEvent) { + rdb = redis.NewClient(&redis.Options{ + Addr: host, + }) + + log.Println("Connected to redis on " + host) + + // Start Redis pub/sub listener + go listenRedis(channel) +} + +func listenRedis(channel chan messages.OsseEvent) { + ctx := context.Background() + pubsub := rdb.Subscribe(ctx, "osse_database_scan") + + for msg := range pubsub.Channel() { + log.Println("Received message:", msg.Payload) + + // Parse the message into a Message type + event, err := messages.GetEventFromMessage(msg.Payload) + if err != nil { + log.Println("Received message from Osse that osse-broadcast cannot parse...") + continue + } + + // Broadcast to connected SSE clients + channel <- event + } +} + +// Gets a redis value from a key. Returns the message and an error +func GetValue(key string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return rdb.Get(ctx, key).Result() +} diff --git a/osse-broadcast/internal/server/middleware.go b/osse-broadcast/internal/server/middleware.go new file mode 100644 index 0000000..87844b2 --- /dev/null +++ b/osse-broadcast/internal/server/middleware.go @@ -0,0 +1,30 @@ +package server + +import ( + "net/http" + "osse-broadcast/internal/redis" +) + +func validateUserToken(userID string, token string) bool { + // Check that the user is permitted to access osse-broadcast + userToken, err := redis.GetValue("osse_database_sse_access:" + userID) + if err != nil { + return false + } + + return userToken == token +} + +func cors(h http.Handler, allowedOrigin string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // If the origin matches the OSSE_HOST env var, we can allow the request. + origin := r.Header.Get("origin") + if origin == "http://"+allowedOrigin || origin == "https://"+allowedOrigin { + w.Header().Set("Access-Control-Allow-Origin", origin) + h.ServeHTTP(w, r) + } else { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.")) + } + }) +} diff --git a/osse-broadcast/internal/server/server.go b/osse-broadcast/internal/server/server.go new file mode 100644 index 0000000..fd9a066 --- /dev/null +++ b/osse-broadcast/internal/server/server.go @@ -0,0 +1,153 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "osse-broadcast/internal/messages" + "osse-broadcast/internal/redis" + "time" + + "github.com/tmaxmax/go-sse" +) + +// SSE connections +var Clients = make(chan messages.OsseEvent) + +func Start(host string, allowOrigin string) { + sseHandler := createSseSetup() + + mux := http.NewServeMux() + // /sse is the only cors route. + mux.Handle("/sse", sseHandler) + mux.HandleFunc("/stream", createFilestreamSetup) + + httpServer := &http.Server{ + Addr: host, + Handler: cors(mux, allowOrigin), + ReadHeaderTimeout: time.Second * 10, + } + + httpServer.RegisterOnShutdown(func() { + e := &sse.Message{Type: sse.Type("close")} + // Adding data is necessary because spec-compliant clients + // do not dispatch events without data. + e.AppendData("bye") + // Broadcast a close message so clients can gracefully disconnect. + _ = sseHandler.Publish(e) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + // We use a context with a timeout so the program doesn't wait indefinitely + // for connections to terminate. There may be misbehaving connections + // which may hang for an unknown timespan, so we just stop waiting on Shutdown + // after a certain duration. + _ = sseHandler.Shutdown(ctx) + }) + + // Listen for redis messages + go func() { + for event := range Clients { + eventJson, err := messages.GetJsonOfEvent(event) + if err != nil { + continue + } + + message := &sse.Message{} + message.AppendData(eventJson) + + // Set the event name (client listens for this) + eventName, err := sse.NewType(event.GetType()) + if err != nil { + continue + } + message.Type = eventName + + sseHandler.Publish(message, messages.AllTopics...) + log.Println("Sent Message to client") + } + }() + + log.Println("Osse Broadcast running on " + host) + runServer(httpServer) +} + +func createSseSetup() *sse.Server { + return &sse.Server{ + Provider: &sse.Joe{}, + OnSession: func(s *sse.Session) (sse.Subscription, bool) { + // Get the user ID and token + userID := s.Req.URL.Query().Get("id") + token := s.Req.URL.Query().Get("token") + + log.Println("User attempted to connect.") + + // Validate the userID and token + if !validateUserToken(userID, token) { + return sse.Subscription{}, false + } + + return sse.Subscription{ + Client: s, + Topics: messages.AllTopics, + }, true + }, + } +} + +func createFilestreamSetup(w http.ResponseWriter, r *http.Request) { + // Read the token and user id + token := r.URL.Query().Get("token") + trackID := r.URL.Query().Get("trackID") + userID := r.URL.Query().Get("id") + + if userID == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + filePath, err := redis.GetValue("osse_database_file_access:" + userID + ":" + trackID + ":" + token) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + } + + // Make sure the file path is absolute (don't serve relatie files, although that should be impossible with how we do this.) + fmt.Println(filePath) + if filePath == "" { + http.Error(w, "invalid file path", http.StatusBadRequest) + return + } + + // Open the file + f, err := os.Open(filePath) + if err != nil { + http.Error(w, "file not found", http.StatusNotFound) + return + } + defer f.Close() + + // Get file info + stat, err := f.Stat() + if err != nil || stat.IsDir() { + http.Error(w, "invalid file", http.StatusBadRequest) + return + } + + // Go literally handles everything I wrote in manual PHP related to HTTP range requests. + // woooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo - amytho, 7:31 PM + http.ServeContent(w, r, stat.Name(), stat.ModTime(), f) +} + +func runServer(s *http.Server) error { + shutdownError := make(chan error) + + if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + + return <-shutdownError +} diff --git a/osse-broadcast/main.go b/osse-broadcast/main.go new file mode 100644 index 0000000..a27c42d --- /dev/null +++ b/osse-broadcast/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "osse-broadcast/internal/config" + "osse-broadcast/internal/redis" + "osse-broadcast/internal/server" +) + +func main() { + // Get config + config := config.GetOsseConfig() + + // Connect to Redis/Valkey + redis.Connect(config.RedisHost, server.Clients) + + // Start HTTP server with SSE route + server.Start(config.HttpHost, config.OsseClientOrigin) +} diff --git a/osse-broadcast/prod-run.sh b/osse-broadcast/prod-run.sh new file mode 100644 index 0000000..71b5199 --- /dev/null +++ b/osse-broadcast/prod-run.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Runs osse-broadcast in prod. Expects a valkey instance with a domain name of valkey. +# Running docker-compose in osse will do this. + +# Wait for redis (valkey) to go online +until nc -z valkey 6379; do + echo "Waiting for Valkey..." + sleep 1 +done + +exec osse-broadcast diff --git a/.dockerignore b/osse-core/.dockerignore similarity index 100% rename from .dockerignore rename to osse-core/.dockerignore diff --git a/.env.docker b/osse-core/.env.docker similarity index 100% rename from .env.docker rename to osse-core/.env.docker diff --git a/.env.example b/osse-core/.env.example similarity index 100% rename from .env.example rename to osse-core/.env.example diff --git a/.env.testing b/osse-core/.env.testing similarity index 100% rename from .env.testing rename to osse-core/.env.testing diff --git a/.gitignore b/osse-core/.gitignore similarity index 100% rename from .gitignore rename to osse-core/.gitignore diff --git a/README.md b/osse-core/README.md similarity index 100% rename from README.md rename to osse-core/README.md diff --git a/_ide_helper.php b/osse-core/_ide_helper.php similarity index 100% rename from _ide_helper.php rename to osse-core/_ide_helper.php diff --git a/app/Enums/ScanDirStatus.php b/osse-core/app/Enums/ScanDirStatus.php similarity index 100% rename from app/Enums/ScanDirStatus.php rename to osse-core/app/Enums/ScanDirStatus.php diff --git a/app/Enums/ScanStatus.php b/osse-core/app/Enums/ScanStatus.php similarity index 100% rename from app/Enums/ScanStatus.php rename to osse-core/app/Enums/ScanStatus.php diff --git a/app/Events/ScanCancelled.php b/osse-core/app/Events/ScanCancelled.php similarity index 100% rename from app/Events/ScanCancelled.php rename to osse-core/app/Events/ScanCancelled.php diff --git a/app/Events/ScanCompleted.php b/osse-core/app/Events/ScanCompleted.php similarity index 100% rename from app/Events/ScanCompleted.php rename to osse-core/app/Events/ScanCompleted.php diff --git a/app/Events/ScanError.php b/osse-core/app/Events/ScanError.php similarity index 100% rename from app/Events/ScanError.php rename to osse-core/app/Events/ScanError.php diff --git a/app/Events/ScanFailed.php b/osse-core/app/Events/ScanFailed.php similarity index 100% rename from app/Events/ScanFailed.php rename to osse-core/app/Events/ScanFailed.php diff --git a/app/Events/ScanProgressed.php b/osse-core/app/Events/ScanProgressed.php similarity index 100% rename from app/Events/ScanProgressed.php rename to osse-core/app/Events/ScanProgressed.php diff --git a/app/Events/ScanStarted.php b/osse-core/app/Events/ScanStarted.php similarity index 100% rename from app/Events/ScanStarted.php rename to osse-core/app/Events/ScanStarted.php diff --git a/app/Http/Controllers/AlbumController.php b/osse-core/app/Http/Controllers/AlbumController.php similarity index 100% rename from app/Http/Controllers/AlbumController.php rename to osse-core/app/Http/Controllers/AlbumController.php diff --git a/app/Http/Controllers/ArtistController.php b/osse-core/app/Http/Controllers/ArtistController.php similarity index 100% rename from app/Http/Controllers/ArtistController.php rename to osse-core/app/Http/Controllers/ArtistController.php diff --git a/app/Http/Controllers/AuthController.php b/osse-core/app/Http/Controllers/AuthController.php similarity index 100% rename from app/Http/Controllers/AuthController.php rename to osse-core/app/Http/Controllers/AuthController.php diff --git a/app/Http/Controllers/ConfigController.php b/osse-core/app/Http/Controllers/ConfigController.php similarity index 100% rename from app/Http/Controllers/ConfigController.php rename to osse-core/app/Http/Controllers/ConfigController.php diff --git a/app/Http/Controllers/Controller.php b/osse-core/app/Http/Controllers/Controller.php similarity index 100% rename from app/Http/Controllers/Controller.php rename to osse-core/app/Http/Controllers/Controller.php diff --git a/app/Http/Controllers/CoverArtController.php b/osse-core/app/Http/Controllers/CoverArtController.php similarity index 100% rename from app/Http/Controllers/CoverArtController.php rename to osse-core/app/Http/Controllers/CoverArtController.php diff --git a/app/Http/Controllers/PlaylistController.php b/osse-core/app/Http/Controllers/PlaylistController.php similarity index 100% rename from app/Http/Controllers/PlaylistController.php rename to osse-core/app/Http/Controllers/PlaylistController.php diff --git a/app/Http/Controllers/QueueController.php b/osse-core/app/Http/Controllers/QueueController.php similarity index 100% rename from app/Http/Controllers/QueueController.php rename to osse-core/app/Http/Controllers/QueueController.php diff --git a/app/Http/Controllers/ScanController.php b/osse-core/app/Http/Controllers/ScanController.php similarity index 100% rename from app/Http/Controllers/ScanController.php rename to osse-core/app/Http/Controllers/ScanController.php diff --git a/app/Http/Controllers/TrackController.php b/osse-core/app/Http/Controllers/TrackController.php similarity index 100% rename from app/Http/Controllers/TrackController.php rename to osse-core/app/Http/Controllers/TrackController.php diff --git a/app/Http/Middleware/HTTPCache.php b/osse-core/app/Http/Middleware/HTTPCache.php similarity index 100% rename from app/Http/Middleware/HTTPCache.php rename to osse-core/app/Http/Middleware/HTTPCache.php diff --git a/app/Http/Middleware/RegistrationCheck.php b/osse-core/app/Http/Middleware/RegistrationCheck.php similarity index 100% rename from app/Http/Middleware/RegistrationCheck.php rename to osse-core/app/Http/Middleware/RegistrationCheck.php diff --git a/app/Http/Requests/CreatePlaylistRequest.php b/osse-core/app/Http/Requests/CreatePlaylistRequest.php similarity index 100% rename from app/Http/Requests/CreatePlaylistRequest.php rename to osse-core/app/Http/Requests/CreatePlaylistRequest.php diff --git a/app/Http/Requests/QueueActiveTrackRequest.php b/osse-core/app/Http/Requests/QueueActiveTrackRequest.php similarity index 100% rename from app/Http/Requests/QueueActiveTrackRequest.php rename to osse-core/app/Http/Requests/QueueActiveTrackRequest.php diff --git a/app/Http/Requests/QueueRequest.php b/osse-core/app/Http/Requests/QueueRequest.php similarity index 100% rename from app/Http/Requests/QueueRequest.php rename to osse-core/app/Http/Requests/QueueRequest.php diff --git a/app/Http/Requests/StoreConfigRequest.php b/osse-core/app/Http/Requests/StoreConfigRequest.php similarity index 100% rename from app/Http/Requests/StoreConfigRequest.php rename to osse-core/app/Http/Requests/StoreConfigRequest.php diff --git a/app/Http/Requests/TrackSearchRequest.php b/osse-core/app/Http/Requests/TrackSearchRequest.php similarity index 100% rename from app/Http/Requests/TrackSearchRequest.php rename to osse-core/app/Http/Requests/TrackSearchRequest.php diff --git a/app/Http/Requests/UpdatePlaylistRequest.php b/osse-core/app/Http/Requests/UpdatePlaylistRequest.php similarity index 100% rename from app/Http/Requests/UpdatePlaylistRequest.php rename to osse-core/app/Http/Requests/UpdatePlaylistRequest.php diff --git a/app/Http/Resources/AlbumResponse.php b/osse-core/app/Http/Resources/AlbumResponse.php similarity index 100% rename from app/Http/Resources/AlbumResponse.php rename to osse-core/app/Http/Resources/AlbumResponse.php diff --git a/app/Jobs/ScanMusic.php b/osse-core/app/Jobs/ScanMusic.php similarity index 100% rename from app/Jobs/ScanMusic.php rename to osse-core/app/Jobs/ScanMusic.php diff --git a/app/Models/Album.php b/osse-core/app/Models/Album.php similarity index 100% rename from app/Models/Album.php rename to osse-core/app/Models/Album.php diff --git a/app/Models/Artist.php b/osse-core/app/Models/Artist.php similarity index 100% rename from app/Models/Artist.php rename to osse-core/app/Models/Artist.php diff --git a/app/Models/CoverArt.php b/osse-core/app/Models/CoverArt.php similarity index 100% rename from app/Models/CoverArt.php rename to osse-core/app/Models/CoverArt.php diff --git a/app/Models/PlaybackSession.php b/osse-core/app/Models/PlaybackSession.php similarity index 100% rename from app/Models/PlaybackSession.php rename to osse-core/app/Models/PlaybackSession.php diff --git a/app/Models/Playlist.php b/osse-core/app/Models/Playlist.php similarity index 100% rename from app/Models/Playlist.php rename to osse-core/app/Models/Playlist.php diff --git a/app/Models/ScanDirectory.php b/osse-core/app/Models/ScanDirectory.php similarity index 100% rename from app/Models/ScanDirectory.php rename to osse-core/app/Models/ScanDirectory.php diff --git a/app/Models/ScanError.php b/osse-core/app/Models/ScanError.php similarity index 100% rename from app/Models/ScanError.php rename to osse-core/app/Models/ScanError.php diff --git a/app/Models/ScanJob.php b/osse-core/app/Models/ScanJob.php similarity index 100% rename from app/Models/ScanJob.php rename to osse-core/app/Models/ScanJob.php diff --git a/app/Models/Track.php b/osse-core/app/Models/Track.php similarity index 100% rename from app/Models/Track.php rename to osse-core/app/Models/Track.php diff --git a/app/Models/User.php b/osse-core/app/Models/User.php similarity index 100% rename from app/Models/User.php rename to osse-core/app/Models/User.php diff --git a/app/Models/UserSetting.php b/osse-core/app/Models/UserSetting.php similarity index 100% rename from app/Models/UserSetting.php rename to osse-core/app/Models/UserSetting.php diff --git a/app/Providers/AppServiceProvider.php b/osse-core/app/Providers/AppServiceProvider.php similarity index 100% rename from app/Providers/AppServiceProvider.php rename to osse-core/app/Providers/AppServiceProvider.php diff --git a/app/Services/MusicProcessor/ArtExtractor.php b/osse-core/app/Services/MusicProcessor/ArtExtractor.php similarity index 100% rename from app/Services/MusicProcessor/ArtExtractor.php rename to osse-core/app/Services/MusicProcessor/ArtExtractor.php diff --git a/app/Services/MusicProcessor/ArtFile.php b/osse-core/app/Services/MusicProcessor/ArtFile.php similarity index 100% rename from app/Services/MusicProcessor/ArtFile.php rename to osse-core/app/Services/MusicProcessor/ArtFile.php diff --git a/app/Services/MusicProcessor/MusicMetadata.php b/osse-core/app/Services/MusicProcessor/MusicMetadata.php similarity index 100% rename from app/Services/MusicProcessor/MusicMetadata.php rename to osse-core/app/Services/MusicProcessor/MusicMetadata.php diff --git a/app/Services/MusicProcessor/MusicProcessor.php b/osse-core/app/Services/MusicProcessor/MusicProcessor.php similarity index 100% rename from app/Services/MusicProcessor/MusicProcessor.php rename to osse-core/app/Services/MusicProcessor/MusicProcessor.php diff --git a/app/Services/MusicProcessor/MusicPruner.php b/osse-core/app/Services/MusicProcessor/MusicPruner.php similarity index 100% rename from app/Services/MusicProcessor/MusicPruner.php rename to osse-core/app/Services/MusicProcessor/MusicPruner.php diff --git a/artisan b/osse-core/artisan similarity index 100% rename from artisan rename to osse-core/artisan diff --git a/bootstrap/app.php b/osse-core/bootstrap/app.php similarity index 100% rename from bootstrap/app.php rename to osse-core/bootstrap/app.php diff --git a/bootstrap/cache/.gitignore b/osse-core/bootstrap/cache/.gitignore similarity index 100% rename from bootstrap/cache/.gitignore rename to osse-core/bootstrap/cache/.gitignore diff --git a/bootstrap/providers.php b/osse-core/bootstrap/providers.php similarity index 100% rename from bootstrap/providers.php rename to osse-core/bootstrap/providers.php diff --git a/composer.json b/osse-core/composer.json similarity index 100% rename from composer.json rename to osse-core/composer.json diff --git a/composer.lock b/osse-core/composer.lock similarity index 100% rename from composer.lock rename to osse-core/composer.lock diff --git a/config/app.php b/osse-core/config/app.php similarity index 100% rename from config/app.php rename to osse-core/config/app.php diff --git a/config/auth.php b/osse-core/config/auth.php similarity index 100% rename from config/auth.php rename to osse-core/config/auth.php diff --git a/config/broadcasting.php b/osse-core/config/broadcasting.php similarity index 100% rename from config/broadcasting.php rename to osse-core/config/broadcasting.php diff --git a/config/cache.php b/osse-core/config/cache.php similarity index 100% rename from config/cache.php rename to osse-core/config/cache.php diff --git a/config/cors.php b/osse-core/config/cors.php similarity index 100% rename from config/cors.php rename to osse-core/config/cors.php diff --git a/config/database.php b/osse-core/config/database.php similarity index 100% rename from config/database.php rename to osse-core/config/database.php diff --git a/config/filesystems.php b/osse-core/config/filesystems.php similarity index 100% rename from config/filesystems.php rename to osse-core/config/filesystems.php diff --git a/config/logging.php b/osse-core/config/logging.php similarity index 100% rename from config/logging.php rename to osse-core/config/logging.php diff --git a/config/mail.php b/osse-core/config/mail.php similarity index 100% rename from config/mail.php rename to osse-core/config/mail.php diff --git a/config/queue.php b/osse-core/config/queue.php similarity index 100% rename from config/queue.php rename to osse-core/config/queue.php diff --git a/config/sanctum.php b/osse-core/config/sanctum.php similarity index 100% rename from config/sanctum.php rename to osse-core/config/sanctum.php diff --git a/config/scan.php b/osse-core/config/scan.php similarity index 100% rename from config/scan.php rename to osse-core/config/scan.php diff --git a/config/services.php b/osse-core/config/services.php similarity index 100% rename from config/services.php rename to osse-core/config/services.php diff --git a/config/session.php b/osse-core/config/session.php similarity index 100% rename from config/session.php rename to osse-core/config/session.php diff --git a/database/.gitignore b/osse-core/database/.gitignore similarity index 100% rename from database/.gitignore rename to osse-core/database/.gitignore diff --git a/database/factories/AlbumFactory.php b/osse-core/database/factories/AlbumFactory.php similarity index 100% rename from database/factories/AlbumFactory.php rename to osse-core/database/factories/AlbumFactory.php diff --git a/database/factories/ArtistFactory.php b/osse-core/database/factories/ArtistFactory.php similarity index 100% rename from database/factories/ArtistFactory.php rename to osse-core/database/factories/ArtistFactory.php diff --git a/database/factories/CoverArtFactory.php b/osse-core/database/factories/CoverArtFactory.php similarity index 100% rename from database/factories/CoverArtFactory.php rename to osse-core/database/factories/CoverArtFactory.php diff --git a/database/factories/PlaylistFactory.php b/osse-core/database/factories/PlaylistFactory.php similarity index 100% rename from database/factories/PlaylistFactory.php rename to osse-core/database/factories/PlaylistFactory.php diff --git a/database/factories/TrackFactory.php b/osse-core/database/factories/TrackFactory.php similarity index 100% rename from database/factories/TrackFactory.php rename to osse-core/database/factories/TrackFactory.php diff --git a/database/factories/UserFactory.php b/osse-core/database/factories/UserFactory.php similarity index 100% rename from database/factories/UserFactory.php rename to osse-core/database/factories/UserFactory.php diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/osse-core/database/migrations/0001_01_01_000000_create_users_table.php similarity index 100% rename from database/migrations/0001_01_01_000000_create_users_table.php rename to osse-core/database/migrations/0001_01_01_000000_create_users_table.php diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/osse-core/database/migrations/0001_01_01_000001_create_cache_table.php similarity index 100% rename from database/migrations/0001_01_01_000001_create_cache_table.php rename to osse-core/database/migrations/0001_01_01_000001_create_cache_table.php diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/osse-core/database/migrations/0001_01_01_000002_create_jobs_table.php similarity index 100% rename from database/migrations/0001_01_01_000002_create_jobs_table.php rename to osse-core/database/migrations/0001_01_01_000002_create_jobs_table.php diff --git a/database/migrations/2024_12_08_181615_create_personal_access_tokens_table.php b/osse-core/database/migrations/2024_12_08_181615_create_personal_access_tokens_table.php similarity index 100% rename from database/migrations/2024_12_08_181615_create_personal_access_tokens_table.php rename to osse-core/database/migrations/2024_12_08_181615_create_personal_access_tokens_table.php diff --git a/database/migrations/2024_12_08_182623_init_app.php b/osse-core/database/migrations/2024_12_08_182623_init_app.php similarity index 100% rename from database/migrations/2024_12_08_182623_init_app.php rename to osse-core/database/migrations/2024_12_08_182623_init_app.php diff --git a/database/migrations/2025_05_18_193956_create_queue_and_settings.php b/osse-core/database/migrations/2025_05_18_193956_create_queue_and_settings.php similarity index 100% rename from database/migrations/2025_05_18_193956_create_queue_and_settings.php rename to osse-core/database/migrations/2025_05_18_193956_create_queue_and_settings.php diff --git a/database/seeders/DatabaseSeeder.php b/osse-core/database/seeders/DatabaseSeeder.php similarity index 100% rename from database/seeders/DatabaseSeeder.php rename to osse-core/database/seeders/DatabaseSeeder.php diff --git a/docker-compose.yml b/osse-core/docker-compose.yml similarity index 99% rename from docker-compose.yml rename to osse-core/docker-compose.yml index 4b6ec83..e22cb16 100644 --- a/docker-compose.yml +++ b/osse-core/docker-compose.yml @@ -68,4 +68,3 @@ volumes: valkey_data: caddy_data: caddy_config: - diff --git a/docker/Caddyfile-http b/osse-core/docker/Caddyfile-http similarity index 100% rename from docker/Caddyfile-http rename to osse-core/docker/Caddyfile-http diff --git a/docker/Caddyfile-https b/osse-core/docker/Caddyfile-https similarity index 100% rename from docker/Caddyfile-https rename to osse-core/docker/Caddyfile-https diff --git a/docker/Dockerfile b/osse-core/docker/Dockerfile similarity index 100% rename from docker/Dockerfile rename to osse-core/docker/Dockerfile diff --git a/docker/entrypoint.sh b/osse-core/docker/entrypoint.sh similarity index 100% rename from docker/entrypoint.sh rename to osse-core/docker/entrypoint.sh diff --git a/docker/supervisor.conf b/osse-core/docker/supervisor.conf similarity index 100% rename from docker/supervisor.conf rename to osse-core/docker/supervisor.conf diff --git a/package-lock.json b/osse-core/package-lock.json similarity index 100% rename from package-lock.json rename to osse-core/package-lock.json diff --git a/package.json b/osse-core/package.json similarity index 100% rename from package.json rename to osse-core/package.json diff --git a/phpunit.xml b/osse-core/phpunit.xml similarity index 100% rename from phpunit.xml rename to osse-core/phpunit.xml diff --git a/postcss.config.js b/osse-core/postcss.config.js similarity index 100% rename from postcss.config.js rename to osse-core/postcss.config.js diff --git a/production-setup.sh b/osse-core/production-setup.sh similarity index 100% rename from production-setup.sh rename to osse-core/production-setup.sh diff --git a/public/.htaccess b/osse-core/public/.htaccess similarity index 100% rename from public/.htaccess rename to osse-core/public/.htaccess diff --git a/public/index.php b/osse-core/public/index.php similarity index 100% rename from public/index.php rename to osse-core/public/index.php diff --git a/public/robots.txt b/osse-core/public/robots.txt similarity index 100% rename from public/robots.txt rename to osse-core/public/robots.txt diff --git a/resources/css/app.css b/osse-core/resources/css/app.css similarity index 100% rename from resources/css/app.css rename to osse-core/resources/css/app.css diff --git a/resources/js/app.js b/osse-core/resources/js/app.js similarity index 100% rename from resources/js/app.js rename to osse-core/resources/js/app.js diff --git a/resources/js/bootstrap.js b/osse-core/resources/js/bootstrap.js similarity index 100% rename from resources/js/bootstrap.js rename to osse-core/resources/js/bootstrap.js diff --git a/resources/views/welcome.blade.php b/osse-core/resources/views/welcome.blade.php similarity index 100% rename from resources/views/welcome.blade.php rename to osse-core/resources/views/welcome.blade.php diff --git a/routes/api.php b/osse-core/routes/api.php similarity index 100% rename from routes/api.php rename to osse-core/routes/api.php diff --git a/routes/console.php b/osse-core/routes/console.php similarity index 100% rename from routes/console.php rename to osse-core/routes/console.php diff --git a/routes/web.php b/osse-core/routes/web.php similarity index 100% rename from routes/web.php rename to osse-core/routes/web.php diff --git a/storage/app/.gitignore b/osse-core/storage/app/.gitignore similarity index 100% rename from storage/app/.gitignore rename to osse-core/storage/app/.gitignore diff --git a/storage/app/private/.gitignore b/osse-core/storage/app/private/.gitignore similarity index 100% rename from storage/app/private/.gitignore rename to osse-core/storage/app/private/.gitignore diff --git a/storage/app/public/.gitignore b/osse-core/storage/app/public/.gitignore similarity index 100% rename from storage/app/public/.gitignore rename to osse-core/storage/app/public/.gitignore diff --git a/storage/framework/.gitignore b/osse-core/storage/framework/.gitignore similarity index 100% rename from storage/framework/.gitignore rename to osse-core/storage/framework/.gitignore diff --git a/storage/framework/cache/.gitignore b/osse-core/storage/framework/cache/.gitignore similarity index 100% rename from storage/framework/cache/.gitignore rename to osse-core/storage/framework/cache/.gitignore diff --git a/storage/framework/cache/data/.gitignore b/osse-core/storage/framework/cache/data/.gitignore similarity index 100% rename from storage/framework/cache/data/.gitignore rename to osse-core/storage/framework/cache/data/.gitignore diff --git a/storage/framework/sessions/.gitignore b/osse-core/storage/framework/sessions/.gitignore similarity index 100% rename from storage/framework/sessions/.gitignore rename to osse-core/storage/framework/sessions/.gitignore diff --git a/storage/framework/testing/.gitignore b/osse-core/storage/framework/testing/.gitignore similarity index 100% rename from storage/framework/testing/.gitignore rename to osse-core/storage/framework/testing/.gitignore diff --git a/storage/framework/views/.gitignore b/osse-core/storage/framework/views/.gitignore similarity index 100% rename from storage/framework/views/.gitignore rename to osse-core/storage/framework/views/.gitignore diff --git a/storage/logs/.gitignore b/osse-core/storage/logs/.gitignore similarity index 100% rename from storage/logs/.gitignore rename to osse-core/storage/logs/.gitignore diff --git a/systemd/Caddyfile-https-mtls b/osse-core/systemd/Caddyfile-https-mtls similarity index 100% rename from systemd/Caddyfile-https-mtls rename to osse-core/systemd/Caddyfile-https-mtls diff --git a/systemd/osse-broadcast.service b/osse-core/systemd/osse-broadcast.service similarity index 100% rename from systemd/osse-broadcast.service rename to osse-core/systemd/osse-broadcast.service diff --git a/systemd/osse-frankenphp.service b/osse-core/systemd/osse-frankenphp.service similarity index 100% rename from systemd/osse-frankenphp.service rename to osse-core/systemd/osse-frankenphp.service diff --git a/systemd/osse-queue.service b/osse-core/systemd/osse-queue.service similarity index 100% rename from systemd/osse-queue.service rename to osse-core/systemd/osse-queue.service diff --git a/systemd/osse.target b/osse-core/systemd/osse.target similarity index 100% rename from systemd/osse.target rename to osse-core/systemd/osse.target diff --git a/tailwind.config.js b/osse-core/tailwind.config.js similarity index 100% rename from tailwind.config.js rename to osse-core/tailwind.config.js diff --git a/tests/Feature/Http/Controllers/ScanControllerTest.php b/osse-core/tests/Feature/Http/Controllers/ScanControllerTest.php similarity index 100% rename from tests/Feature/Http/Controllers/ScanControllerTest.php rename to osse-core/tests/Feature/Http/Controllers/ScanControllerTest.php diff --git a/tests/Feature/Http/Middleware/RegistrationCheckTest.php b/osse-core/tests/Feature/Http/Middleware/RegistrationCheckTest.php similarity index 100% rename from tests/Feature/Http/Middleware/RegistrationCheckTest.php rename to osse-core/tests/Feature/Http/Middleware/RegistrationCheckTest.php diff --git a/tests/Feature/Jobs/ScanMusicTest.php b/osse-core/tests/Feature/Jobs/ScanMusicTest.php similarity index 100% rename from tests/Feature/Jobs/ScanMusicTest.php rename to osse-core/tests/Feature/Jobs/ScanMusicTest.php diff --git a/tests/TestCase.php b/osse-core/tests/TestCase.php similarity index 100% rename from tests/TestCase.php rename to osse-core/tests/TestCase.php diff --git a/tests/Unit/ExampleTest.php b/osse-core/tests/Unit/ExampleTest.php similarity index 100% rename from tests/Unit/ExampleTest.php rename to osse-core/tests/Unit/ExampleTest.php diff --git a/tests/files/covers/test_cover.mp3 b/osse-core/tests/files/covers/test_cover.mp3 similarity index 100% rename from tests/files/covers/test_cover.mp3 rename to osse-core/tests/files/covers/test_cover.mp3 diff --git a/tests/files/covers/test_same_cover.mp3 b/osse-core/tests/files/covers/test_same_cover.mp3 similarity index 100% rename from tests/files/covers/test_same_cover.mp3 rename to osse-core/tests/files/covers/test_same_cover.mp3 diff --git a/tests/files/has_metadata/track_one.mp3 b/osse-core/tests/files/has_metadata/track_one.mp3 similarity index 100% rename from tests/files/has_metadata/track_one.mp3 rename to osse-core/tests/files/has_metadata/track_one.mp3 diff --git a/tests/files/has_metadata/track_two.mp3 b/osse-core/tests/files/has_metadata/track_two.mp3 similarity index 100% rename from tests/files/has_metadata/track_two.mp3 rename to osse-core/tests/files/has_metadata/track_two.mp3 diff --git a/tests/files/invalid/bad_file.mp3 b/osse-core/tests/files/invalid/bad_file.mp3 similarity index 100% rename from tests/files/invalid/bad_file.mp3 rename to osse-core/tests/files/invalid/bad_file.mp3 diff --git a/tests/files/no_metadata/test_no_metadata.mp3 b/osse-core/tests/files/no_metadata/test_no_metadata.mp3 similarity index 100% rename from tests/files/no_metadata/test_no_metadata.mp3 rename to osse-core/tests/files/no_metadata/test_no_metadata.mp3 diff --git a/vite.config.js b/osse-core/vite.config.js similarity index 100% rename from vite.config.js rename to osse-core/vite.config.js diff --git a/osse-web/.editorconfig b/osse-web/.editorconfig new file mode 100644 index 0000000..59d9a3a --- /dev/null +++ b/osse-web/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/osse-web/.gitignore b/osse-web/.gitignore new file mode 100644 index 0000000..0711527 --- /dev/null +++ b/osse-web/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/osse-web/.postcssrc.json b/osse-web/.postcssrc.json new file mode 100644 index 0000000..e092dc7 --- /dev/null +++ b/osse-web/.postcssrc.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/osse-web/LICENSE b/osse-web/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/osse-web/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/osse-web/README.md b/osse-web/README.md new file mode 100644 index 0000000..a683787 --- /dev/null +++ b/osse-web/README.md @@ -0,0 +1,54 @@ +# Osse-Web + +Osse is a free and open source music player and server. This repository is the web frontend. + +## Features + +> Osse is in early development. There will be bugs and unexpected behavior. Some features are not yet complete. It is safe to use on your library, but it will need some time before it can be your main music player. + +- Supports most music formats (MP3, Ogg/Opus, Flac, WAV). +- Support reading tags for library generation. +- Album & Playlist support. +- No Tracking/Telemetry/Data collection. +- Simplicity. Install it and it will just work. +- Support for Linux/Mac/Windows (Mac/Windows need Docker or other medium). Any OS (including Android/IOS) can use the web frontend. + +## Installation + +Both the server and the web frontend (this project) must be installed. + +> When v1 releases, we will provide a standalone installer/executable to simplify this process. We will also provide docker images. Currently, you must manually install the projects and their dependencies. + +You will need the following tools installed: + +- Git https://git-scm.com/downloads +- PHP 8.4 `/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.4)"` +- NodeJS v22 https://nodejs.org/en +- PNPM (optional, preferred over NPM) https://pnpm.io/installation + +Clone this repository and the server. + +``` +git clone https://github.com/amytho/osse +cd +git clone https://github.com/amytho/osse-web +``` + +Start the web frontend and the php backend. + +``` +cd osse +composer run dev +``` + +In another terminal window: +``` +cd osse-web +pnpm start +``` + +Open the web frontend and login. http://localhost:4200 + +The default username is `osse` and the default password is `cassidor`. + +You should edit the .env file in the server and add your music directory to it. This is located at the bottom of the file. diff --git a/osse-web/angular.json b/osse-web/angular.json new file mode 100644 index 0000000..170ead0 --- /dev/null +++ b/osse-web/angular.json @@ -0,0 +1,117 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "pnpm", + "analytics": false + }, + "newProjectRoot": "projects", + "projects": { + "osse-web": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "inlineStyle": true, + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "outputPath": "dist/osse-web", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [], + "tsConfig": "tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "5kb", + "maximumError": "15kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "osse-web:build:production" + }, + "development": { + "buildTarget": "osse-web:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n", + "options": { + "buildTarget": "osse-web:build" + } + }, + "test": { + "builder": "@angular/build:unit-test", + "options": {} + } + } + } + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } + } +} diff --git a/osse-web/package.json b/osse-web/package.json new file mode 100644 index 0000000..4fe659d --- /dev/null +++ b/osse-web/package.json @@ -0,0 +1,43 @@ +{ + "name": "osse-web", + "version": "0.0.1", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test", + "update": "ng update" + }, + "private": true, + "dependencies": { + "@angular/animations": "^21.0.1", + "@angular/common": "^21.0.1", + "@angular/compiler": "^21.0.1", + "@angular/core": "^21.0.1", + "@angular/forms": "^21.0.1", + "@angular/platform-browser": "^21.0.1", + "@angular/platform-browser-dynamic": "^21.0.1", + "@angular/router": "^21.0.1", + "@mdi/js": "^7.4.47", + "@tailwindcss/postcss": "^4.1.4", + "daisyui": "^5.0.27", + "rxjs": "~7.8.2", + "tslib": "^2.8.1", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@angular/build": "^21.0.1", + "@angular/cli": "^21.0.1", + "@angular/compiler-cli": "^21.0.1", + "@angular/language-service": "^21.0.1", + "@types/jasmine": "~5.1.7", + "autoprefixer": "^10.4.21", + "jasmine-core": "~5.1.2", + "jsdom": "^27.4.0", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.4", + "typescript": "~5.9.3", + "vitest": "^4.0.16" + } +} \ No newline at end of file diff --git a/osse-web/pnpm-lock.yaml b/osse-web/pnpm-lock.yaml new file mode 100644 index 0000000..5b30dde --- /dev/null +++ b/osse-web/pnpm-lock.yaml @@ -0,0 +1,7294 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@angular/animations': + specifier: ^21.0.1 + version: 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/common': + specifier: ^21.0.1 + version: 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/compiler': + specifier: ^21.0.1 + version: 21.0.1 + '@angular/core': + specifier: ^21.0.1 + version: 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/forms': + specifier: ^21.0.1 + version: 21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@standard-schema/spec@1.0.0)(rxjs@7.8.2) + '@angular/platform-browser': + specifier: ^21.0.1 + version: 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/platform-browser-dynamic': + specifier: ^21.0.1 + version: 21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.0.1)(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))) + '@angular/router': + specifier: ^21.0.1 + version: 21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) + '@mdi/js': + specifier: ^7.4.47 + version: 7.4.47 + '@tailwindcss/postcss': + specifier: ^4.1.4 + version: 4.1.4 + daisyui: + specifier: ^5.0.27 + version: 5.0.27 + rxjs: + specifier: ~7.8.2 + version: 7.8.2 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + uuid: + specifier: ^11.1.0 + version: 11.1.0 + devDependencies: + '@angular/build': + specifier: ^21.0.1 + version: 21.0.1(@angular/compiler-cli@21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3))(@angular/compiler@21.0.1)(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@22.13.10)(chokidar@4.0.3)(jiti@2.6.1)(karma@6.4.4)(less@4.4.2)(lightningcss@1.29.2)(postcss@8.5.3)(tailwindcss@4.1.4)(terser@5.44.0)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0))(yaml@2.7.0) + '@angular/cli': + specifier: ^21.0.1 + version: 21.0.1(@types/node@22.13.10)(chokidar@4.0.3) + '@angular/compiler-cli': + specifier: ^21.0.1 + version: 21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3) + '@angular/language-service': + specifier: ^21.0.1 + version: 21.0.1 + '@types/jasmine': + specifier: ~5.1.7 + version: 5.1.7 + autoprefixer: + specifier: ^10.4.21 + version: 10.4.21(postcss@8.5.3) + jasmine-core: + specifier: ~5.1.2 + version: 5.1.2 + jsdom: + specifier: ^27.4.0 + version: 27.4.0 + postcss: + specifier: ^8.5.3 + version: 8.5.3 + tailwindcss: + specifier: ^4.1.4 + version: 4.1.4 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + +packages: + + '@acemir/cssom@0.9.30': + resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} + + '@algolia/abtesting@1.6.1': + resolution: {integrity: sha512-wV/gNRkzb7sI9vs1OneG129hwe3Q5zPj7zigz3Ps7M5Lpo2hSorrOnXNodHEOV+yXE/ks4Pd+G3CDFIjFTWhMQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-abtesting@5.40.1': + resolution: {integrity: sha512-cxKNATPY5t+Mv8XAVTI57altkaPH+DZi4uMrnexPxPHODMljhGYY+GDZyHwv9a+8CbZHcY372OkxXrDMZA4Lnw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.40.1': + resolution: {integrity: sha512-XP008aMffJCRGAY8/70t+hyEyvqqV7YKm502VPu0+Ji30oefrTn2al7LXkITz7CK6I4eYXWRhN6NaIUi65F1OA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.40.1': + resolution: {integrity: sha512-gWfQuQUBtzUboJv/apVGZMoxSaB0M4Imwl1c9Ap+HpCW7V0KhjBddqF2QQt5tJZCOFsfNIgBbZDGsEPaeKUosw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.40.1': + resolution: {integrity: sha512-RTLjST/t+lsLMouQ4zeLJq2Ss+UNkLGyNVu+yWHanx6kQ3LT5jv8UvPwyht9s7R6jCPnlSI77WnL80J32ZuyJg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.40.1': + resolution: {integrity: sha512-2FEK6bUomBzEYkTKzD0iRs7Ljtjb45rKK/VSkyHqeJnG+77qx557IeSO0qVFE3SfzapNcoytTofnZum0BQ6r3Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.40.1': + resolution: {integrity: sha512-Nju4NtxAvXjrV2hHZNLKVJLXjOlW6jAXHef/CwNzk1b2qIrCWDO589ELi5ZHH1uiWYoYyBXDQTtHmhaOVVoyXg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.40.1': + resolution: {integrity: sha512-Mw6pAUF121MfngQtcUb5quZVqMC68pSYYjCRZkSITC085S3zdk+h/g7i6FxnVdbSU6OztxikSDMh1r7Z+4iPlA==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.40.1': + resolution: {integrity: sha512-z+BPlhs45VURKJIxsR99NNBWpUEEqIgwt10v/fATlNxc4UlXvALdOsWzaFfe89/lbP5Bu4+mbO59nqBC87ZM/g==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.40.1': + resolution: {integrity: sha512-VJMUMbO0wD8Rd2VVV/nlFtLJsOAQvjnVNGkMkspFiFhpBA7s/xJOb+fJvvqwKFUjbKTUA7DjiSi1ljSMYBasXg==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.40.1': + resolution: {integrity: sha512-ehvJLadKVwTp9Scg9NfzVSlBKH34KoWOQNTaN8i1Ac64AnO6iH2apJVSP6GOxssaghZ/s8mFQsDH3QIZoluFHA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.40.1': + resolution: {integrity: sha512-PbidVsPurUSQIr6X9/7s34mgOMdJnn0i6p+N6Ab+lsNhY5eiu+S33kZEpZwkITYBCIbhzDLOvb7xZD3gDi+USA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.40.1': + resolution: {integrity: sha512-ThZ5j6uOZCF11fMw9IBkhigjOYdXGXQpj6h4k+T9UkZrF2RlKcPynFzDeRgaLdpYk8Yn3/MnFbwUmib7yxj5Lw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.40.1': + resolution: {integrity: sha512-H1gYPojO6krWHnUXu/T44DrEun/Wl95PJzMXRcM/szstNQczSbwq6wIFJPI9nyE95tarZfUNU3rgorT+wZ6iCQ==} + engines: {node: '>= 14.0.0'} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@angular-devkit/architect@0.2100.1': + resolution: {integrity: sha512-MLxTT6EE7NHuCen9yGdv9iT2vtB/fAdXTRnulOWfVa/SVmGoKawBGCNOAPpI2yA8Fb/D5xlU6ThS1ggDsiCqrQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/core@21.0.1': + resolution: {integrity: sha512-AGdAu0hV2TLCWYHiyVSxUFbpR2chO+xA4OkRrG2YirQGcqJTmr651C4rWDkheWqeWDxMicZklqKaTw66mNSUkw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics@21.0.1': + resolution: {integrity: sha512-3koB1xJNkqMg7g6JwH2rhQO268WjnPVA852lwoLW7wzSZRpJH0kHtZsnY9FYOC2kbmAGnCWWbnPLJ5/T1wemoA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular/animations@21.0.1': + resolution: {integrity: sha512-P7i/jpNnzXwo0vHEG0cDXYojwTz0WQlXJHrmOJzLVveyfcFwgXYXJxhGGUI2+k21YrlJTKkR/4QZTEJ0GP0f8Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/core': 21.0.1 + + '@angular/build@21.0.1': + resolution: {integrity: sha512-AQFZWG5TtujCRs7ncajeBZpl/hLBKkuF0lZSziJL8tsgBru0hz0OobOkEuS/nb3FuCRQfva8YP2EPhLdcuo50g==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + '@angular/compiler': ^21.0.0 + '@angular/compiler-cli': ^21.0.0 + '@angular/core': ^21.0.0 + '@angular/localize': ^21.0.0 + '@angular/platform-browser': ^21.0.0 + '@angular/platform-server': ^21.0.0 + '@angular/service-worker': ^21.0.0 + '@angular/ssr': ^21.0.1 + karma: ^6.4.0 + less: ^4.2.0 + ng-packagr: ^21.0.0 + postcss: ^8.4.0 + tailwindcss: ^2.0.0 || ^3.0.0 || ^4.0.0 + tslib: ^2.3.0 + typescript: '>=5.9 <6.0' + vitest: ^4.0.8 + peerDependenciesMeta: + '@angular/core': + optional: true + '@angular/localize': + optional: true + '@angular/platform-browser': + optional: true + '@angular/platform-server': + optional: true + '@angular/service-worker': + optional: true + '@angular/ssr': + optional: true + karma: + optional: true + less: + optional: true + ng-packagr: + optional: true + postcss: + optional: true + tailwindcss: + optional: true + vitest: + optional: true + + '@angular/cli@21.0.1': + resolution: {integrity: sha512-i0+7jwf19D73yAzR/lL4+eKVhooM+J055qfSaJWL5QLCF9/JSSjMPCG8I/qIGNdVr+lVmWvvxqpt7O7kR3zfUw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular/common@21.0.1': + resolution: {integrity: sha512-EqdTGpFp7PVdTVztO7TB6+QxdzUbYXKKT2jwG2Gg+PIQZ2A8XrLPRmGXyH/DLlc5IhnoJlLbngmBRCLCO4xWog==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/core': 21.0.1 + rxjs: ^6.5.3 || ^7.4.0 + + '@angular/compiler-cli@21.0.1': + resolution: {integrity: sha512-BxGLtL5bxlaaAs/kSN4oyXhMfvzqsj1Gc4Jauz39R4xtgOF5cIvjBtj6dJ9mD3PK0s6zaFi7WYd0YwWkxhjgMA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@angular/compiler': 21.0.1 + typescript: '>=5.9 <6.0' + peerDependenciesMeta: + typescript: + optional: true + + '@angular/compiler@21.0.1': + resolution: {integrity: sha512-YRzHpThgCaC9b3xzK1Wx859ePeHEPR7ewQklUB5TYbpzVacvnJo38PcSAx/nzOmgX9y4mgyros6LzECmBb8d8w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@angular/core@21.0.1': + resolution: {integrity: sha512-z0G9Bwzgqr0fQVbtMgqwl+SbbiqtJD7I2xT6U5p45LetKHojcfigH29dxi/vqALPwEdgb2nSIx7RqVhoyynraQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/compiler': 21.0.1 + rxjs: ^6.5.3 || ^7.4.0 + zone.js: ~0.15.0 || ~0.16.0 + peerDependenciesMeta: + '@angular/compiler': + optional: true + zone.js: + optional: true + + '@angular/forms@21.0.1': + resolution: {integrity: sha512-BVFPuKjxkzjzKMmpc6KxUKICpVs6J2/KzA4HjtPp/UKvdZPe8dj8vIXuc9pGf8DA4XdkjCwvv8szCgzTWi02LQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/common': 21.0.1 + '@angular/core': 21.0.1 + '@angular/platform-browser': 21.0.1 + '@standard-schema/spec': ^1.0.0 + rxjs: ^6.5.3 || ^7.4.0 + + '@angular/language-service@21.0.1': + resolution: {integrity: sha512-+QohcgWbgrsPsHFhbie1ZQaNsnoBpuVK7479WZXPyFiw4PWEceNuF0hSr9yrSNEh/kvgCu9BfJSzVf7w5Yj39A==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@angular/platform-browser-dynamic@21.0.1': + resolution: {integrity: sha512-TzCKf3p1NBK1NYoPJXLScSjVeiQ52DaXf9gweNUGtCmX3EkVKf1sx4Ny1x4DxaTwB5XZn+O+L3nVLstPBj7UGA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/common': 21.0.1 + '@angular/compiler': 21.0.1 + '@angular/core': 21.0.1 + '@angular/platform-browser': 21.0.1 + + '@angular/platform-browser@21.0.1': + resolution: {integrity: sha512-68StH9HILKUqNhQKz6KKNHzpgk1n88CIusWlmJvnb0l6iWGf3ydq5lTMKAKiZQmSDAVP5unTGfNvIkh59GRyVg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/animations': 21.0.1 + '@angular/common': 21.0.1 + '@angular/core': 21.0.1 + peerDependenciesMeta: + '@angular/animations': + optional: true + + '@angular/router@21.0.1': + resolution: {integrity: sha512-EnNbiScESZ0op9XS9qUNncWc1UcSYy90uCbDMVTTChikZt9b+e19OusFMf50zecb96VMMz+BzNY1see7Rmvx4g==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/common': 21.0.1 + '@angular/core': 21.0.1 + '@angular/platform-browser': 21.0.1 + rxjs: ^6.5.3 || ^7.4.0 + + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.0': + resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.4': + resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-split-export-declaration@7.24.7': + resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': + resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@emnapi/core@1.5.0': + resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + + '@emnapi/runtime@1.5.0': + resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@esbuild/aix-ppc64@0.25.9': + resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.26.0': + resolution: {integrity: sha512-hj0sKNCQOOo2fgyII3clmJXP28VhgDfU5iy3GNHlWO76KG6N7x4D9ezH5lJtQTG+1J6MFDAJXC1qsI+W+LvZoA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.9': + resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.26.0': + resolution: {integrity: sha512-DDnoJ5eoa13L8zPh87PUlRd/IyFaIKOlRbxiwcSbeumcJ7UZKdtuMCHa1Q27LWQggug6W4m28i4/O2qiQQ5NZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.9': + resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.26.0': + resolution: {integrity: sha512-C0hkDsYNHZkBtPxxDx177JN90/1MiCpvBNjz1f5yWJo1+5+c5zr8apjastpEG+wtPjo9FFtGG7owSsAxyKiHxA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.9': + resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.26.0': + resolution: {integrity: sha512-bKDkGXGZnj0T70cRpgmv549x38Vr2O3UWLbjT2qmIkdIWcmlg8yebcFWoT9Dku7b5OV3UqPEuNKRzlNhjwUJ9A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.9': + resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.26.0': + resolution: {integrity: sha512-6Z3naJgOuAIB0RLlJkYc81An3rTlQ/IeRdrU3dOea8h/PvZSgitZV+thNuIccw0MuK1GmIAnAmd5TrMZad8FTQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.9': + resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.26.0': + resolution: {integrity: sha512-OPnYj0zpYW0tHusMefyaMvNYQX5pNQuSsHFTHUBNp3vVXupwqpxofcjVsUx11CQhGVkGeXjC3WLjh91hgBG2xw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.9': + resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.26.0': + resolution: {integrity: sha512-jix2fa6GQeZhO1sCKNaNMjfj5hbOvoL2F5t+w6gEPxALumkpOV/wq7oUBMHBn2hY2dOm+mEV/K+xfZy3mrsxNQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.9': + resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.26.0': + resolution: {integrity: sha512-tccJaH5xHJD/239LjbVvJwf6T4kSzbk6wPFerF0uwWlkw/u7HL+wnAzAH5GB2irGhYemDgiNTp8wJzhAHQ64oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.9': + resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.26.0': + resolution: {integrity: sha512-IMJYN7FSkLttYyTbsbme0Ra14cBO5z47kpamo16IwggzzATFY2lcZAwkbcNkWiAduKrTgFJP7fW5cBI7FzcuNQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.9': + resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.26.0': + resolution: {integrity: sha512-JY8NyU31SyRmRpuc5W8PQarAx4TvuYbyxbPIpHAZdr/0g4iBr8KwQBS4kiiamGl2f42BBecHusYCsyxi7Kn8UQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.9': + resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.26.0': + resolution: {integrity: sha512-XITaGqGVLgk8WOHw8We9Z1L0lbLFip8LyQzKYFKO4zFo1PFaaSKsbNjvkb7O8kEXytmSGRkYpE8LLVpPJpsSlw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.9': + resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.26.0': + resolution: {integrity: sha512-MkggfbDIczStUJwq9wU7gQ7kO33d8j9lWuOCDifN9t47+PeI+9m2QVh51EI/zZQ1spZtFMC1nzBJ+qNGCjJnsg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.9': + resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.26.0': + resolution: {integrity: sha512-fUYup12HZWAeccNLhQ5HwNBPr4zXCPgUWzEq2Rfw7UwqwfQrFZ0SR/JljaURR8xIh9t+o1lNUFTECUTmaP7yKA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.9': + resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.26.0': + resolution: {integrity: sha512-MzRKhM0Ip+//VYwC8tialCiwUQ4G65WfALtJEFyU0GKJzfTYoPBw5XNWf0SLbCUYQbxTKamlVwPmcw4DgZzFxg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.9': + resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.26.0': + resolution: {integrity: sha512-QhCc32CwI1I4Jrg1enCv292sm3YJprW8WHHlyxJhae/dVs+KRWkbvz2Nynl5HmZDW/m9ZxrXayHzjzVNvQMGQA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.9': + resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.26.0': + resolution: {integrity: sha512-1D6vi6lfI18aNT1aTf2HV+RIlm6fxtlAp8eOJ4mmnbYmZ4boz8zYDar86sIYNh0wmiLJEbW/EocaKAX6Yso2fw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.9': + resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.26.0': + resolution: {integrity: sha512-rnDcepj7LjrKFvZkx+WrBv6wECeYACcFjdNPvVPojCPJD8nHpb3pv3AuR9CXgdnjH1O23btICj0rsp0L9wAnHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.9': + resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.26.0': + resolution: {integrity: sha512-FSWmgGp0mDNjEXXFcsf12BmVrb+sZBBBlyh3LwB/B9ac3Kkc8x5D2WimYW9N7SUkolui8JzVnVlWh7ZmjCpnxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.9': + resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.26.0': + resolution: {integrity: sha512-0QfciUDFryD39QoSPUDshj4uNEjQhp73+3pbSAaxjV2qGOEDsM67P7KbJq7LzHoVl46oqhIhJ1S+skKGR7lMXA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.9': + resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.26.0': + resolution: {integrity: sha512-vmAK+nHhIZWImwJ3RNw9hX3fU4UGN/OqbSE0imqljNbUQC3GvVJ1jpwYoTfD6mmXmQaxdJY6Hn4jQbLGJKg5Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.9': + resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.26.0': + resolution: {integrity: sha512-GPXF7RMkJ7o9bTyUsnyNtrFMqgM3X+uM/LWw4CeHIjqc32fm0Ir6jKDnWHpj8xHFstgWDUYseSABK9KCkHGnpg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.9': + resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.26.0': + resolution: {integrity: sha512-nUHZ5jEYqbBthbiBksbmHTlbb5eElyVfs/s1iHQ8rLBq1eWsd5maOnDpCocw1OM8kFK747d1Xms8dXJHtduxSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.9': + resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.26.0': + resolution: {integrity: sha512-TMg3KCTCYYaVO+R6P5mSORhcNDDlemUVnUbb8QkboUtOhb5JWKAzd5uMIMECJQOxHZ/R+N8HHtDF5ylzLfMiLw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.9': + resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.26.0': + resolution: {integrity: sha512-apqYgoAUd6ZCb9Phcs8zN32q6l0ZQzQBdVXOofa6WvHDlSOhwCWgSfVQabGViThS40Y1NA4SCvQickgZMFZRlA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.9': + resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.26.0': + resolution: {integrity: sha512-FGJAcImbJNZzLWu7U6WB0iKHl4RuY4TsXEwxJPl9UZLS47agIZuILZEX3Pagfw7I4J3ddflomt9f0apfaJSbaw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.9': + resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.26.0': + resolution: {integrity: sha512-WAckBKaVnmFqbEhbymrPK7M086DQMpL1XoRbpmN0iW8k5JSXjDRQBhcZNa0VweItknLq9eAeCL34jK7/CDcw7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.7.0': + resolution: {integrity: sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@exodus/crypto': ^1.0.0-rc.4 + peerDependenciesMeta: + '@exodus/crypto': + optional: true + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.19': + resolution: {integrity: sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.9.0': + resolution: {integrity: sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.8': + resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.6': + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.30': + resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + + '@listr2/prompt-adapter-inquirer@3.0.5': + resolution: {integrity: sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@inquirer/prompts': '>= 3 < 8' + listr2: 9.0.5 + + '@lmdb/lmdb-darwin-arm64@3.4.3': + resolution: {integrity: sha512-zR6Y45VNtW5s+A+4AyhrJk0VJKhXdkLhrySCpCu7PSdnakebsOzNxf58p5Xoq66vOSuueGAxlqDAF49HwdrSTQ==} + cpu: [arm64] + os: [darwin] + + '@lmdb/lmdb-darwin-x64@3.4.3': + resolution: {integrity: sha512-nfGm5pQksBGfaj9uMbjC0YyQreny/Pl7mIDtHtw6g7WQuCgeLullr9FNRsYyKplaEJBPrCVpEjpAznxTBIrXBw==} + cpu: [x64] + os: [darwin] + + '@lmdb/lmdb-linux-arm64@3.4.3': + resolution: {integrity: sha512-uX9eaPqWb740wg5D3TCvU/js23lSRSKT7lJrrQ8IuEG/VLgpPlxO3lHDywU44yFYdGS7pElBn6ioKFKhvALZlw==} + cpu: [arm64] + os: [linux] + + '@lmdb/lmdb-linux-arm@3.4.3': + resolution: {integrity: sha512-Kjqomp7i0rgSbYSUmv9JnXpS55zYT/YcW3Bdf9oqOTjcH0/8tFAP8MLhu/i9V2pMKIURDZk63Ww49DTK0T3c/Q==} + cpu: [arm] + os: [linux] + + '@lmdb/lmdb-linux-x64@3.4.3': + resolution: {integrity: sha512-7/8l20D55CfwdMupkc3fNxNJdn4bHsti2X0cp6PwiXlLeSFvAfWs5kCCx+2Cyje4l4GtN//LtKWjTru/9hDJQg==} + cpu: [x64] + os: [linux] + + '@lmdb/lmdb-win32-arm64@3.4.3': + resolution: {integrity: sha512-yWVR0e5Gl35EGJBsAuqPOdjtUYuN8CcTLKrqpQFoM+KsMadViVCulhKNhkcjSGJB88Am5bRPjMro4MBB9FS23Q==} + cpu: [arm64] + os: [win32] + + '@lmdb/lmdb-win32-x64@3.4.3': + resolution: {integrity: sha512-1JdBkcO0Vrua4LUgr4jAe4FUyluwCeq/pDkBrlaVjX3/BBWP1TzVjCL+TibWNQtPAL1BITXPAhlK5Ru4FBd/hg==} + cpu: [x64] + os: [win32] + + '@mdi/js@7.4.47': + resolution: {integrity: sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==} + + '@modelcontextprotocol/sdk@1.20.1': + resolution: {integrity: sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==} + engines: {node: '>=18'} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@napi-rs/nice-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/nice-android-arm64@1.1.1': + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/nice-darwin-arm64@1.1.1': + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/nice-darwin-x64@1.1.1': + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/nice-freebsd-x64@1.1.1': + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-openharmony-arm64@1.1.1': + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [openharmony] + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/nice@1.1.1': + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + + '@npmcli/agent@4.0.0': + resolution: {integrity: sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/fs@5.0.0': + resolution: {integrity: sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/git@7.0.1': + resolution: {integrity: sha512-+XTFxK2jJF/EJJ5SoAzXk3qwIDfvFc5/g+bD274LZ7uY7LE8sTfG6Z8rOanPl2ZEvZWqNvmEdtXC25cE54VcoA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/installed-package-contents@3.0.0': + resolution: {integrity: sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + '@npmcli/node-gyp@5.0.0': + resolution: {integrity: sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/package-json@7.0.4': + resolution: {integrity: sha512-0wInJG3j/K40OJt/33ax47WfWMzZTm6OQxB9cDhTt5huCP2a9g2GnlsxmfN+PulItNPIpPrZ+kfwwUil7eHcZQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/promise-spawn@8.0.2': + resolution: {integrity: sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@npmcli/promise-spawn@9.0.1': + resolution: {integrity: sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/redact@4.0.0': + resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/run-script@10.0.3': + resolution: {integrity: sha512-ER2N6itRkzWbbtVmZ9WKaWxVlKlOeBFF1/7xx+KA5J1xKa4JjUwBdb6tDpk0v1qA+d+VDwHI9qmLcXSWcmi+Rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@oxc-project/types@0.96.0': + resolution: {integrity: sha512-r/xkmoXA0xEpU6UGtn18CNVjXH6erU3KCpCDbpLmbVxBFor1U9MqN5Z2uMmCHJuXjJzlnDR+hWY+yPoLo8oHDw==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rolldown/binding-android-arm64@1.0.0-beta.47': + resolution: {integrity: sha512-vPP9/MZzESh9QtmvQYojXP/midjgkkc1E4AdnPPAzQXo668ncHJcVLKjJKzoBdsQmaIvNjrMdsCwES8vTQHRQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.47': + resolution: {integrity: sha512-Lc3nrkxeaDVCVl8qR3qoxh6ltDZfkQ98j5vwIr5ALPkgjZtDK4BGCrrBoLpGVMg+csWcaqUbwbKwH5yvVa0oOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.47': + resolution: {integrity: sha512-eBYxQDwP0O33plqNVqOtUHqRiSYVneAknviM5XMawke3mwMuVlAsohtOqEjbCEl/Loi/FWdVeks5WkqAkzkYWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.47': + resolution: {integrity: sha512-Ns+kgp2+1Iq/44bY/Z30DETUSiHY7ZuqaOgD5bHVW++8vme9rdiWsN4yG4rRPXkdgzjvQ9TDHmZZKfY4/G11AA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.47': + resolution: {integrity: sha512-4PecgWCJhTA2EFOlptYJiNyVP2MrVP4cWdndpOu3WmXqWqZUmSubhb4YUAIxAxnXATlGjC1WjxNPhV7ZllNgdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.47': + resolution: {integrity: sha512-CyIunZ6D9U9Xg94roQI1INt/bLkOpPsZjZZkiaAZ0r6uccQdICmC99M9RUPlMLw/qg4yEWLlQhG73W/mG437NA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.47': + resolution: {integrity: sha512-doozc/Goe7qRCSnzfJbFINTHsMktqmZQmweull6hsZZ9sjNWQ6BWQnbvOlfZJe4xE5NxM1NhPnY5Giqnl3ZrYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.47': + resolution: {integrity: sha512-fodvSMf6Aqwa0wEUSTPewmmZOD44rc5Tpr5p9NkwQ6W1SSpUKzD3SwpJIgANDOhwiYhDuiIaYPGB7Ujkx1q0UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.47': + resolution: {integrity: sha512-Rxm5hYc0mGjwLh5sjlGmMygxAaV2gnsx7CNm2lsb47oyt5UQyPDZf3GP/ct8BEcwuikdqzsrrlIp8+kCSvMFNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.47': + resolution: {integrity: sha512-YakuVe+Gc87jjxazBL34hbr8RJpRuFBhun7NEqoChVDlH5FLhLXjAPHqZd990TVGVNkemourf817Z8u2fONS8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.47': + resolution: {integrity: sha512-ak2GvTFQz3UAOw8cuQq8pWE+TNygQB6O47rMhvevvTzETh7VkHRFtRUwJynX5hwzFvQMP6G0az5JrBGuwaMwYQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.47': + resolution: {integrity: sha512-o5BpmBnXU+Cj+9+ndMcdKjhZlPb79dVPBZnWwMnI4RlNSSq5yOvFZqvfPYbyacvnW03Na4n5XXQAPhu3RydZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.47': + resolution: {integrity: sha512-FVOmfyYehNE92IfC9Kgs913UerDog2M1m+FADJypKz0gmRg3UyTt4o1cZMCAl7MiR89JpM9jegNO1nXuP1w1vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.47': + resolution: {integrity: sha512-by/70F13IUE101Bat0oeH8miwWX5mhMFPk1yjCdxoTNHTyTdLgb0THNaebRM6AP7Kz+O3O2qx87sruYuF5UxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.47': + resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==} + + '@rollup/rollup-android-arm-eabi@4.50.0': + resolution: {integrity: sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.50.0': + resolution: {integrity: sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.50.0': + resolution: {integrity: sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.50.0': + resolution: {integrity: sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.50.0': + resolution: {integrity: sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.50.0': + resolution: {integrity: sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.50.0': + resolution: {integrity: sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.50.0': + resolution: {integrity: sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.50.0': + resolution: {integrity: sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.50.0': + resolution: {integrity: sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.50.0': + resolution: {integrity: sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.50.0': + resolution: {integrity: sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.50.0': + resolution: {integrity: sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.50.0': + resolution: {integrity: sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.50.0': + resolution: {integrity: sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.50.0': + resolution: {integrity: sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.50.0': + resolution: {integrity: sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.50.0': + resolution: {integrity: sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.0': + resolution: {integrity: sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.50.0': + resolution: {integrity: sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.50.0': + resolution: {integrity: sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==} + cpu: [x64] + os: [win32] + + '@schematics/angular@21.0.1': + resolution: {integrity: sha512-m7Z/gykPxOyC5Gs9nkFkGwYTc5xLNLcVkjjZPcYszycwsWBohDREjQLZzRG86AauWFYy8mBUrTF9CD63ZqYHeQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@sigstore/bundle@4.0.0': + resolution: {integrity: sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/core@3.0.0': + resolution: {integrity: sha512-NgbJ+aW9gQl/25+GIEGYcCyi8M+ng2/5X04BMuIgoDfgvp18vDcoNHOQjQsG9418HGNYRxG3vfEXaR1ayD37gg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/protobuf-specs@0.5.0': + resolution: {integrity: sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@sigstore/sign@4.0.1': + resolution: {integrity: sha512-KFNGy01gx9Y3IBPG/CergxR9RZpN43N+lt3EozEfeoyqm8vEiLxwRl3ZO5sPx3Obv1ix/p7FWOlPc2Jgwfp9PA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/tuf@4.0.0': + resolution: {integrity: sha512-0QFuWDHOQmz7t66gfpfNO6aEjoFrdhkJaej/AOqb4kqWZVbPWFZifXZzkxyQBB1OwTbkhdT3LNpMFxwkTvf+2w==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/verify@3.0.0': + resolution: {integrity: sha512-moXtHH33AobOhTZF8xcX1MpOFqdvfCk7v6+teJL8zymBiDXwEsQH6XG9HGx2VIxnJZNm4cNSzflTLDnQLmIdmw==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + + '@tailwindcss/node@4.1.4': + resolution: {integrity: sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==} + + '@tailwindcss/oxide-android-arm64@4.1.4': + resolution: {integrity: sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.4': + resolution: {integrity: sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.4': + resolution: {integrity: sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.4': + resolution: {integrity: sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': + resolution: {integrity: sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': + resolution: {integrity: sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.4': + resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.4': + resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.4': + resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.4': + resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': + resolution: {integrity: sha512-VlnhfilPlO0ltxW9/BgfLI5547PYzqBMPIzRrk4W7uupgCt8z6Trw/tAj6QUtF2om+1MH281Pg+HHUJoLesmng==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.4': + resolution: {integrity: sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.4': + resolution: {integrity: sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==} + engines: {node: '>= 10'} + + '@tailwindcss/postcss@4.1.4': + resolution: {integrity: sha512-bjV6sqycCEa+AQSt2Kr7wpGF1bOZJ5wsqnLEkqSbM/JEHxx/yhMH8wHmdkPyApF9xhHeMSwnnkDUUMMM/hYnXw==} + + '@tufjs/canonical-json@2.0.0': + resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@tufjs/models@4.0.0': + resolution: {integrity: sha512-h5x5ga/hh82COe+GoD4+gKUeV4T3iaYOxqLt41GRKApinPI7DMidhCmNVTjKfhCWFJIGXaFJee07XczdT4jdZQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/cors@2.8.17': + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/jasmine@5.1.7': + resolution: {integrity: sha512-DVOfk9FaClQfNFpSfaML15jjB5cjffDMvjtph525sroR5BEAW2uKnTOYUTqTFuZFjNvH0T5XMIydvIctnUKufw==} + + '@types/node@22.13.10': + resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} + + '@vitejs/plugin-basic-ssl@2.1.0': + resolution: {integrity: sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + peerDependencies: + vite: ^6.0.0 || ^7.0.0 + + '@vitest/expect@4.0.16': + resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + + '@vitest/mocker@4.0.16': + resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + + '@vitest/runner@4.0.16': + resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + + '@vitest/snapshot@4.0.16': + resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + + '@vitest/spy@4.0.16': + resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + + '@yarnpkg/lockfile@1.1.0': + resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + algoliasearch@5.40.1: + resolution: {integrity: sha512-iUNxcXUNg9085TJx0HJLjqtDE0r1RZ0GOGrt8KNQqQT5ugu8lZsHuMUYW/e0lHhq6xBvmktU9Bw4CXP9VQeKrg==} + engines: {node: '>= 14.0.0'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + + baseline-browser-mapping@2.8.32: + resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} + hasBin: true + + beasties@0.3.5: + resolution: {integrity: sha512-NaWu+f4YrJxEttJSm16AzMIFtVldCvaJ68b1L098KpqXmxt9xOLtKoLkKxb8ekhOrLqEJAbvT6n6SEvB/sac7A==} + engines: {node: '>=14.0.0'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + browserslist@4.28.0: + resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cacache@20.0.3: + resolution: {integrity: sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==} + engines: {node: ^20.17.0 || >=22.9.0} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001705: + resolution: {integrity: sha512-S0uyMMiYvA7CxNgomYBwwwPUnWzFD83f3B1ce5jHUfHTH//QL6hHsreI8RVC5606R4ssqravelYO5TU6t8sEyg==} + + caniuse-lite@1.0.30001757: + resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@3.3.0: + resolution: {integrity: sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ==} + engines: {node: '>=18.20'} + + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + copy-anything@2.0.6: + resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + cssstyle@5.3.6: + resolution: {integrity: sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==} + engines: {node: '>=20'} + + custom-event@1.0.1: + resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==} + + daisyui@5.0.27: + resolution: {integrity: sha512-XrpqgfpGaZJvTPg9pS9Rq6xbYpmMnR0a7AKqyVPZceJzjAs5HH3rfkRkiuGin0+KC2Adnu+WLHU7UDxAtCMyAw==} + + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} + engines: {node: '>=20'} + + date-format@4.0.14: + resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} + engines: {node: '>=4.0'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + di@0.0.1: + resolution: {integrity: sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==} + + dom-serialize@2.2.1: + resolution: {integrity: sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.119: + resolution: {integrity: sha512-Ku4NMzUjz3e3Vweh7PhApPrZSS4fyiCIbcIrG9eKrriYVLmbMepETR/v6SU7xPm98QTqMSYiCwfO89QNjXLkbQ==} + + electron-to-chromium@1.5.262: + resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.4: + resolution: {integrity: sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==} + engines: {node: '>=10.2.0'} + + enhanced-resolve@5.18.1: + resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} + engines: {node: '>=10.13.0'} + + ent@2.2.2: + resolution: {integrity: sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==} + engines: {node: '>= 0.4'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.0: + resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.9: + resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.26.0: + resolution: {integrity: sha512-3Hq7jri+tRrVWha+ZeIVhl4qJRha/XjRNSopvTsOaCvfPHrflTYTcUFcEjMKdxofsXXsdc4zjg5NOTnL4Gl57Q==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exponential-backoff@3.1.2: + resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==} + + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-uri@3.0.6: + resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hosted-git-info@9.0.0: + resolution: {integrity: sha512-gEf705MZLrDPkbbhi8PnoO4ZwYgKoNL+ISZ3AjZMht2r3N5tuTwncyDi6Fv2/qDnMmZxgs0yI8WDOyR8q3G+SQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + + http-cache-semantics@4.1.1: + resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + ignore-walk@8.0.0: + resolution: {integrity: sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==} + engines: {node: ^20.17.0 || >=22.9.0} + + image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + immutable@5.0.3: + resolution: {integrity: sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@5.0.0: + resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==} + engines: {node: ^18.17.0 || >=20.5.0} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + ip-address@9.0.5: + resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-what@3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jasmine-core@5.1.2: + resolution: {integrity: sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==} + + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsbn@1.1.0: + resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@5.0.0: + resolution: {integrity: sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + + karma@6.4.4: + resolution: {integrity: sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==} + engines: {node: '>= 10'} + hasBin: true + + less@4.4.2: + resolution: {integrity: sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==} + engines: {node: '>=14'} + hasBin: true + + lightningcss-darwin-arm64@1.29.2: + resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.29.2: + resolution: {integrity: sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.29.2: + resolution: {integrity: sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.29.2: + resolution: {integrity: sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.29.2: + resolution: {integrity: sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.29.2: + resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.29.2: + resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.29.2: + resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.29.2: + resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.29.2: + resolution: {integrity: sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.29.2: + resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==} + engines: {node: '>= 12.0.0'} + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + lmdb@3.4.3: + resolution: {integrity: sha512-GWV1kVi6uhrXWqe+3NXWO73OYe8fto6q8JMo0HOpk1vf8nEyFWgo4CSNJpIFzsOxOrysVUlcO48qRbQfmKd1gA==} + hasBin: true + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + log4js@6.9.1: + resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==} + engines: {node: '>=8.0'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + make-fetch-happen@15.0.3: + resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} + engines: {node: ^20.17.0 || >=22.9.0} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@5.0.0: + resolution: {integrity: sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + minipass-flush@1.0.5: + resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.0.1: + resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==} + engines: {node: '>= 18'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.2: + resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.10: + resolution: {integrity: sha512-vSJJTG+t/dIKAUhUDw/dLdZ9s//5OxcHqLaDWWrW4Cdq7o6tdLIczUkMXt2MBNmk6sJRZBZRXVixs7URY1CmIg==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + needle@3.3.1: + resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} + engines: {node: '>= 4.4.x'} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-gyp@12.1.0: + resolution: {integrity: sha512-W+RYA8jBnhSr2vrTtlPYPc1K+CSjGpVDRZxcqJcERZ8ND3A1ThWPHRwctTx3qC3oW99jt726jhdz3Y6ky87J4g==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + npm-bundled@4.0.0: + resolution: {integrity: sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==} + engines: {node: ^18.17.0 || >=20.5.0} + + npm-install-checks@8.0.0: + resolution: {integrity: sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-normalize-package-bin@4.0.0: + resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} + engines: {node: ^18.17.0 || >=20.5.0} + + npm-normalize-package-bin@5.0.0: + resolution: {integrity: sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-package-arg@13.0.1: + resolution: {integrity: sha512-6zqls5xFvJbgFjB1B2U6yITtyGBjDBORB7suI4zA4T/sZ1OmkMFlaQSNB/4K0LtXNA1t4OprAFxPisadK5O2ag==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-packlist@10.0.3: + resolution: {integrity: sha512-zPukTwJMOu5X5uvm0fztwS5Zxyvmk38H/LfidkOMt3gbZVCyro2cD/ETzwzVPcWZA3JOyPznfUN/nkyFiyUbxg==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-pick-manifest@11.0.3: + resolution: {integrity: sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-registry-fetch@19.1.1: + resolution: {integrity: sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==} + engines: {node: ^20.17.0 || >=22.9.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + ora@9.0.0: + resolution: {integrity: sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==} + engines: {node: '>=20'} + + ordered-binary@1.5.3: + resolution: {integrity: sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==} + + p-map@7.0.3: + resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} + engines: {node: '>=18'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pacote@21.0.3: + resolution: {integrity: sha512-itdFlanxO0nmQv4ORsvA9K1wv40IPfB9OmWqfaJWvoJ30VKyHsqNgDVeG+TVhI7Gk7XW8slUy7cA9r6dF5qohw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + parse-node-version@1.0.1: + resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} + engines: {node: '>= 0.10'} + + parse5-html-rewriting-stream@8.0.0: + resolution: {integrity: sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==} + + parse5-sax-parser@8.0.0: + resolution: {integrity: sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + + path-to-regexp@8.2.0: + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + piscina@5.1.3: + resolution: {integrity: sha512-0u3N7H4+hbr40KjuVn2uNhOcthu/9usKhnw5vT3J7ply79v3D3M8naI00el9Klcy16x557VsEkkUQaHCWFXC/g==} + engines: {node: '>=20.x'} + + pkce-challenge@5.0.0: + resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} + engines: {node: '>=16.20.0'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proc-log@5.0.0: + resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qjobs@1.2.0: + resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==} + engines: {node: '>=0.9'} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + raw-body@3.0.0: + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} + engines: {node: '>= 0.8'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + + rolldown@1.0.0-beta.47: + resolution: {integrity: sha512-Mid74GckX1OeFAOYz9KuXeWYhq3xkXbMziYIC+ULVdUzPTG9y70OBSBQDQn9hQP8u/AfhuYw1R0BSg15nBI4Dg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.50.0: + resolution: {integrity: sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.93.2: + resolution: {integrity: sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==} + engines: {node: '>=14.0.0'} + hasBin: true + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sigstore@4.0.0: + resolution: {integrity: sha512-Gw/FgHtrLM9WP8P5lLcSGh9OQcrTruWCELAiS48ik1QbL0cH+dfjomiRTUE9zzz+D1N6rOLkwXUvVmXZAsNE0Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socket.io-adapter@2.5.5: + resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} + engines: {node: '>=10.2.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.4: + resolution: {integrity: sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.21: + resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + ssri@12.0.0: + resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + ssri@13.0.0: + resolution: {integrity: sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==} + engines: {node: ^20.17.0 || >=22.9.0} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + streamroller@3.1.5: + resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==} + engines: {node: '>=8.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.1.4: + resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + + tar@7.5.2: + resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} + engines: {node: '>=18'} + + terser@5.44.0: + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + engines: {node: '>=10'} + hasBin: true + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tuf-js@4.0.0: + resolution: {integrity: sha512-Lq7ieeGvXDXwpoSmOSgLWVdsGGV9J4a77oDTAPe/Ltrqnnm/ETaRlBAQTH5JatEh8KXuE6sddf9qAv1Q2282Hg==} + engines: {node: ^20.17.0 || >=22.9.0} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@0.7.40: + resolution: {integrity: sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==} + hasBin: true + + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + + unique-filename@5.0.0: + resolution: {integrity: sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==} + engines: {node: ^20.17.0 || >=22.9.0} + + unique-slug@6.0.0: + resolution: {integrity: sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==} + engines: {node: ^20.17.0 || >=22.9.0} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validate-npm-package-name@6.0.0: + resolution: {integrity: sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==} + engines: {node: ^18.17.0 || >=20.5.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.2.2: + resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.16: + resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.16 + '@vitest/browser-preview': 4.0.16 + '@vitest/browser-webdriverio': 4.0.16 + '@vitest/ui': 4.0.16 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + void-elements@2.0.1: + resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==} + engines: {node: '>=0.10.0'} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + watchpack@2.4.4: + resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} + engines: {node: '>=10.13.0'} + + weak-lru-cache@1.2.2: + resolution: {integrity: sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==} + + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.0: + resolution: {integrity: sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.7.0: + resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod-to-json-schema@3.24.6: + resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} + peerDependencies: + zod: ^3.24.1 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zone.js@0.15.0: + resolution: {integrity: sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==} + +snapshots: + + '@acemir/cssom@0.9.30': {} + + '@algolia/abtesting@1.6.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-abtesting@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-analytics@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-common@5.40.1': {} + + '@algolia/client-insights@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-personalization@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-query-suggestions@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-search@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/ingestion@1.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/monitoring@1.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/recommend@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/requester-browser-xhr@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + + '@algolia/requester-fetch@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + + '@algolia/requester-node-http@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + + '@angular-devkit/architect@0.2100.1(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 21.0.1(chokidar@4.0.3) + rxjs: 7.8.2 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/core@21.0.1(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.3 + rxjs: 7.8.2 + source-map: 0.7.6 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics@21.0.1(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 21.0.1(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.19 + ora: 9.0.0 + rxjs: 7.8.2 + transitivePeerDependencies: + - chokidar + + '@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))': + dependencies: + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + tslib: 2.8.1 + + '@angular/build@21.0.1(@angular/compiler-cli@21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3))(@angular/compiler@21.0.1)(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@22.13.10)(chokidar@4.0.3)(jiti@2.6.1)(karma@6.4.4)(less@4.4.2)(lightningcss@1.29.2)(postcss@8.5.3)(tailwindcss@4.1.4)(terser@5.44.0)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0))(yaml@2.7.0)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@angular-devkit/architect': 0.2100.1(chokidar@4.0.3) + '@angular/compiler': 21.0.1 + '@angular/compiler-cli': 21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3) + '@babel/core': 7.28.4 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-split-export-declaration': 7.24.7 + '@inquirer/confirm': 5.1.19(@types/node@22.13.10) + '@vitejs/plugin-basic-ssl': 2.1.0(vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0)) + beasties: 0.3.5 + browserslist: 4.28.0 + esbuild: 0.26.0 + https-proxy-agent: 7.0.6 + istanbul-lib-instrument: 6.0.3 + jsonc-parser: 3.3.1 + listr2: 9.0.5 + magic-string: 0.30.19 + mrmime: 2.0.1 + parse5-html-rewriting-stream: 8.0.0 + picomatch: 4.0.3 + piscina: 5.1.3 + rolldown: 1.0.0-beta.47 + sass: 1.93.2 + semver: 7.7.3 + source-map-support: 0.5.21 + tinyglobby: 0.2.15 + tslib: 2.8.1 + typescript: 5.9.3 + undici: 7.16.0 + vite: 7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + watchpack: 2.4.4 + optionalDependencies: + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + karma: 6.4.4 + less: 4.4.2 + lmdb: 3.4.3 + postcss: 8.5.3 + tailwindcss: 4.1.4 + vitest: 4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - chokidar + - jiti + - lightningcss + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + '@angular/cli@21.0.1(@types/node@22.13.10)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/architect': 0.2100.1(chokidar@4.0.3) + '@angular-devkit/core': 21.0.1(chokidar@4.0.3) + '@angular-devkit/schematics': 21.0.1(chokidar@4.0.3) + '@inquirer/prompts': 7.9.0(@types/node@22.13.10) + '@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.9.0(@types/node@22.13.10))(@types/node@22.13.10)(listr2@9.0.5) + '@modelcontextprotocol/sdk': 1.20.1 + '@schematics/angular': 21.0.1(chokidar@4.0.3) + '@yarnpkg/lockfile': 1.1.0 + algoliasearch: 5.40.1 + ini: 5.0.0 + jsonc-parser: 3.3.1 + listr2: 9.0.5 + npm-package-arg: 13.0.1 + pacote: 21.0.3 + parse5-html-rewriting-stream: 8.0.0 + resolve: 1.22.11 + semver: 7.7.3 + yargs: 18.0.0 + zod: 3.25.76 + transitivePeerDependencies: + - '@types/node' + - chokidar + - supports-color + + '@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': + dependencies: + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + rxjs: 7.8.2 + tslib: 2.8.1 + + '@angular/compiler-cli@21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3)': + dependencies: + '@angular/compiler': 21.0.1 + '@babel/core': 7.28.4 + '@jridgewell/sourcemap-codec': 1.5.0 + chokidar: 4.0.3 + convert-source-map: 1.9.0 + reflect-metadata: 0.2.2 + semver: 7.7.2 + tslib: 2.8.1 + yargs: 18.0.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@angular/compiler@21.0.1': + dependencies: + tslib: 2.8.1 + + '@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)': + dependencies: + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@angular/compiler': 21.0.1 + zone.js: 0.15.0 + + '@angular/forms@21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@standard-schema/spec@1.0.0)(rxjs@7.8.2)': + dependencies: + '@angular/common': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + '@standard-schema/spec': 1.0.0 + rxjs: 7.8.2 + tslib: 2.8.1 + + '@angular/language-service@21.0.1': {} + + '@angular/platform-browser-dynamic@21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.0.1)(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))': + dependencies: + '@angular/common': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/compiler': 21.0.1 + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + tslib: 2.8.1 + + '@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))': + dependencies: + '@angular/common': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + tslib: 2.8.1 + optionalDependencies: + '@angular/animations': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + + '@angular/router@21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': + dependencies: + '@angular/common': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + rxjs: 7.8.2 + tslib: 2.8.1 + + '@asamuzakjp/css-color@4.1.1': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.0': {} + + '@babel/core@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.1 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.5 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-split-export-declaration@7.24.7': + dependencies: + '@babel/types': 7.28.5 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@colors/colors@1.5.0': + optional: true + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@emnapi/core@1.5.0': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.5.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.9': + optional: true + + '@esbuild/aix-ppc64@0.26.0': + optional: true + + '@esbuild/android-arm64@0.25.9': + optional: true + + '@esbuild/android-arm64@0.26.0': + optional: true + + '@esbuild/android-arm@0.25.9': + optional: true + + '@esbuild/android-arm@0.26.0': + optional: true + + '@esbuild/android-x64@0.25.9': + optional: true + + '@esbuild/android-x64@0.26.0': + optional: true + + '@esbuild/darwin-arm64@0.25.9': + optional: true + + '@esbuild/darwin-arm64@0.26.0': + optional: true + + '@esbuild/darwin-x64@0.25.9': + optional: true + + '@esbuild/darwin-x64@0.26.0': + optional: true + + '@esbuild/freebsd-arm64@0.25.9': + optional: true + + '@esbuild/freebsd-arm64@0.26.0': + optional: true + + '@esbuild/freebsd-x64@0.25.9': + optional: true + + '@esbuild/freebsd-x64@0.26.0': + optional: true + + '@esbuild/linux-arm64@0.25.9': + optional: true + + '@esbuild/linux-arm64@0.26.0': + optional: true + + '@esbuild/linux-arm@0.25.9': + optional: true + + '@esbuild/linux-arm@0.26.0': + optional: true + + '@esbuild/linux-ia32@0.25.9': + optional: true + + '@esbuild/linux-ia32@0.26.0': + optional: true + + '@esbuild/linux-loong64@0.25.9': + optional: true + + '@esbuild/linux-loong64@0.26.0': + optional: true + + '@esbuild/linux-mips64el@0.25.9': + optional: true + + '@esbuild/linux-mips64el@0.26.0': + optional: true + + '@esbuild/linux-ppc64@0.25.9': + optional: true + + '@esbuild/linux-ppc64@0.26.0': + optional: true + + '@esbuild/linux-riscv64@0.25.9': + optional: true + + '@esbuild/linux-riscv64@0.26.0': + optional: true + + '@esbuild/linux-s390x@0.25.9': + optional: true + + '@esbuild/linux-s390x@0.26.0': + optional: true + + '@esbuild/linux-x64@0.25.9': + optional: true + + '@esbuild/linux-x64@0.26.0': + optional: true + + '@esbuild/netbsd-arm64@0.25.9': + optional: true + + '@esbuild/netbsd-arm64@0.26.0': + optional: true + + '@esbuild/netbsd-x64@0.25.9': + optional: true + + '@esbuild/netbsd-x64@0.26.0': + optional: true + + '@esbuild/openbsd-arm64@0.25.9': + optional: true + + '@esbuild/openbsd-arm64@0.26.0': + optional: true + + '@esbuild/openbsd-x64@0.25.9': + optional: true + + '@esbuild/openbsd-x64@0.26.0': + optional: true + + '@esbuild/openharmony-arm64@0.25.9': + optional: true + + '@esbuild/openharmony-arm64@0.26.0': + optional: true + + '@esbuild/sunos-x64@0.25.9': + optional: true + + '@esbuild/sunos-x64@0.26.0': + optional: true + + '@esbuild/win32-arm64@0.25.9': + optional: true + + '@esbuild/win32-arm64@0.26.0': + optional: true + + '@esbuild/win32-ia32@0.25.9': + optional: true + + '@esbuild/win32-ia32@0.26.0': + optional: true + + '@esbuild/win32-x64@0.25.9': + optional: true + + '@esbuild/win32-x64@0.26.0': + optional: true + + '@exodus/bytes@1.7.0': {} + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@22.13.10)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/confirm@5.1.19(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/confirm@5.1.21(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/core@10.3.2(@types/node@22.13.10)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.13.10) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/editor@4.2.23(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/external-editor': 1.0.3(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/expand@4.0.23(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/external-editor@1.0.3(@types/node@22.13.10)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/number@3.0.23(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/password@4.0.23(@types/node@22.13.10)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/prompts@7.9.0(@types/node@22.13.10)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.13.10) + '@inquirer/confirm': 5.1.21(@types/node@22.13.10) + '@inquirer/editor': 4.2.23(@types/node@22.13.10) + '@inquirer/expand': 4.0.23(@types/node@22.13.10) + '@inquirer/input': 4.3.1(@types/node@22.13.10) + '@inquirer/number': 3.0.23(@types/node@22.13.10) + '@inquirer/password': 4.0.23(@types/node@22.13.10) + '@inquirer/rawlist': 4.1.11(@types/node@22.13.10) + '@inquirer/search': 3.2.2(@types/node@22.13.10) + '@inquirer/select': 4.4.2(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/rawlist@4.1.11(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/search@3.2.2(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/select@4.4.2(@types/node@22.13.10)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/type@3.0.10(@types/node@22.13.10)': + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/type@3.0.8(@types/node@22.13.10)': + optionalDependencies: + '@types/node': 22.13.10 + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.30 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.6': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + optional: true + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.30': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@listr2/prompt-adapter-inquirer@3.0.5(@inquirer/prompts@7.9.0(@types/node@22.13.10))(@types/node@22.13.10)(listr2@9.0.5)': + dependencies: + '@inquirer/prompts': 7.9.0(@types/node@22.13.10) + '@inquirer/type': 3.0.8(@types/node@22.13.10) + listr2: 9.0.5 + transitivePeerDependencies: + - '@types/node' + + '@lmdb/lmdb-darwin-arm64@3.4.3': + optional: true + + '@lmdb/lmdb-darwin-x64@3.4.3': + optional: true + + '@lmdb/lmdb-linux-arm64@3.4.3': + optional: true + + '@lmdb/lmdb-linux-arm@3.4.3': + optional: true + + '@lmdb/lmdb-linux-x64@3.4.3': + optional: true + + '@lmdb/lmdb-win32-arm64@3.4.3': + optional: true + + '@lmdb/lmdb-win32-x64@3.4.3': + optional: true + + '@mdi/js@7.4.47': {} + + '@modelcontextprotocol/sdk@1.20.1': + dependencies: + ajv: 6.12.6 + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.1.0 + express-rate-limit: 7.5.1(express@5.1.0) + pkce-challenge: 5.0.0 + raw-body: 3.0.0 + zod: 3.25.76 + zod-to-json-schema: 3.24.6(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@napi-rs/nice-android-arm-eabi@1.1.1': + optional: true + + '@napi-rs/nice-android-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-x64@1.1.1': + optional: true + + '@napi-rs/nice-freebsd-x64@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + optional: true + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-musl@1.1.1': + optional: true + + '@napi-rs/nice-openharmony-arm64@1.1.1': + optional: true + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + optional: true + + '@napi-rs/nice@1.1.1': + optionalDependencies: + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 + optional: true + + '@napi-rs/wasm-runtime@1.0.7': + dependencies: + '@emnapi/core': 1.5.0 + '@emnapi/runtime': 1.5.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@npmcli/agent@4.0.0': + dependencies: + agent-base: 7.1.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 11.2.4 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/fs@5.0.0': + dependencies: + semver: 7.7.3 + + '@npmcli/git@7.0.1': + dependencies: + '@npmcli/promise-spawn': 9.0.1 + ini: 6.0.0 + lru-cache: 11.2.4 + npm-pick-manifest: 11.0.3 + proc-log: 6.1.0 + promise-retry: 2.0.1 + semver: 7.7.3 + which: 6.0.0 + + '@npmcli/installed-package-contents@3.0.0': + dependencies: + npm-bundled: 4.0.0 + npm-normalize-package-bin: 4.0.0 + + '@npmcli/node-gyp@5.0.0': {} + + '@npmcli/package-json@7.0.4': + dependencies: + '@npmcli/git': 7.0.1 + glob: 13.0.0 + hosted-git-info: 9.0.0 + json-parse-even-better-errors: 5.0.0 + proc-log: 6.1.0 + semver: 7.7.3 + validate-npm-package-license: 3.0.4 + + '@npmcli/promise-spawn@8.0.2': + dependencies: + which: 5.0.0 + + '@npmcli/promise-spawn@9.0.1': + dependencies: + which: 6.0.0 + + '@npmcli/redact@4.0.0': {} + + '@npmcli/run-script@10.0.3': + dependencies: + '@npmcli/node-gyp': 5.0.0 + '@npmcli/package-json': 7.0.4 + '@npmcli/promise-spawn': 9.0.1 + node-gyp: 12.1.0 + proc-log: 6.1.0 + which: 6.0.0 + transitivePeerDependencies: + - supports-color + + '@oxc-project/types@0.96.0': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.47': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.47': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.47': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.47': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.47': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.47': {} + + '@rollup/rollup-android-arm-eabi@4.50.0': + optional: true + + '@rollup/rollup-android-arm64@4.50.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.50.0': + optional: true + + '@rollup/rollup-darwin-x64@4.50.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.50.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.50.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.50.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.50.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.50.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.50.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.50.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.50.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.50.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.50.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.50.0': + optional: true + + '@schematics/angular@21.0.1(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 21.0.1(chokidar@4.0.3) + '@angular-devkit/schematics': 21.0.1(chokidar@4.0.3) + jsonc-parser: 3.3.1 + transitivePeerDependencies: + - chokidar + + '@sigstore/bundle@4.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.0 + + '@sigstore/core@3.0.0': {} + + '@sigstore/protobuf-specs@0.5.0': {} + + '@sigstore/sign@4.0.1': + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.0.0 + '@sigstore/protobuf-specs': 0.5.0 + make-fetch-happen: 15.0.3 + proc-log: 5.0.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@sigstore/tuf@4.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.0 + tuf-js: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@sigstore/verify@3.0.0': + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.0.0 + '@sigstore/protobuf-specs': 0.5.0 + + '@socket.io/component-emitter@3.1.2': + optional: true + + '@standard-schema/spec@1.0.0': {} + + '@tailwindcss/node@4.1.4': + dependencies: + enhanced-resolve: 5.18.1 + jiti: 2.4.2 + lightningcss: 1.29.2 + tailwindcss: 4.1.4 + + '@tailwindcss/oxide-android-arm64@4.1.4': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.4': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.4': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.4': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.4': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.4': + optional: true + + '@tailwindcss/oxide@4.1.4': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.4 + '@tailwindcss/oxide-darwin-arm64': 4.1.4 + '@tailwindcss/oxide-darwin-x64': 4.1.4 + '@tailwindcss/oxide-freebsd-x64': 4.1.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.4 + '@tailwindcss/oxide-linux-x64-musl': 4.1.4 + '@tailwindcss/oxide-wasm32-wasi': 4.1.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.4 + + '@tailwindcss/postcss@4.1.4': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.4 + '@tailwindcss/oxide': 4.1.4 + postcss: 8.5.3 + tailwindcss: 4.1.4 + + '@tufjs/canonical-json@2.0.0': {} + + '@tufjs/models@4.0.0': + dependencies: + '@tufjs/canonical-json': 2.0.0 + minimatch: 9.0.5 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/cors@2.8.17': + dependencies: + '@types/node': 22.13.10 + optional: true + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/jasmine@5.1.7': {} + + '@types/node@22.13.10': + dependencies: + undici-types: 6.20.0 + optional: true + + '@vitejs/plugin-basic-ssl@2.1.0(vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0))': + dependencies: + vite: 7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + + '@vitest/expect@4.0.16': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.16(vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0))': + dependencies: + '@vitest/spy': 4.0.16 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + + '@vitest/pretty-format@4.0.16': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.16': + dependencies: + '@vitest/utils': 4.0.16 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.16': {} + + '@vitest/utils@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + tinyrainbow: 3.0.3 + + '@yarnpkg/lockfile@1.1.0': {} + + abbrev@4.0.0: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + optional: true + + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + + acorn@8.15.0: + optional: true + + agent-base@7.1.3: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + algoliasearch@5.40.1: + dependencies: + '@algolia/abtesting': 1.6.1 + '@algolia/client-abtesting': 5.40.1 + '@algolia/client-analytics': 5.40.1 + '@algolia/client-common': 5.40.1 + '@algolia/client-insights': 5.40.1 + '@algolia/client-personalization': 5.40.1 + '@algolia/client-query-suggestions': 5.40.1 + '@algolia/client-search': 5.40.1 + '@algolia/ingestion': 1.40.1 + '@algolia/monitoring': 1.40.1 + '@algolia/recommend': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + optional: true + + assertion-error@2.0.1: {} + + autoprefixer@10.4.21(postcss@8.5.3): + dependencies: + browserslist: 4.24.4 + caniuse-lite: 1.0.30001705 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + + balanced-match@1.0.2: {} + + base64id@2.0.0: + optional: true + + baseline-browser-mapping@2.8.32: {} + + beasties@0.3.5: + dependencies: + css-select: 6.0.0 + css-what: 7.0.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + htmlparser2: 10.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-media-query-parser: 0.2.3 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: + optional: true + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + optional: true + + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.1 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.0 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + optional: true + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + optional: true + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001705 + electron-to-chromium: 1.5.119 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.4) + + browserslist@4.28.0: + dependencies: + baseline-browser-mapping: 2.8.32 + caniuse-lite: 1.0.30001757 + electron-to-chromium: 1.5.262 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.28.0) + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + cacache@20.0.3: + dependencies: + '@npmcli/fs': 5.0.0 + fs-minipass: 3.0.3 + glob: 13.0.0 + lru-cache: 11.1.0 + minipass: 7.1.2 + minipass-collect: 2.0.1 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + p-map: 7.0.3 + ssri: 13.0.0 + unique-filename: 5.0.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001705: {} + + caniuse-lite@1.0.30001757: {} + + chai@6.2.2: {} + + chalk@5.6.2: {} + + chardet@2.1.1: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + optional: true + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@3.0.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@3.3.0: {} + + cli-truncate@5.1.1: + dependencies: + slice-ansi: 7.1.0 + string-width: 8.1.0 + + cli-width@4.1.0: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + optional: true + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + commander@2.20.3: + optional: true + + concat-map@0.0.1: + optional: true + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + optional: true + + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + copy-anything@2.0.6: + dependencies: + is-what: 3.14.1 + optional: true + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + css-what@7.0.0: {} + + cssstyle@5.3.6: + dependencies: + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.22 + css-tree: 3.1.0 + lru-cache: 11.2.4 + + custom-event@1.0.1: + optional: true + + daisyui@5.0.27: {} + + data-urls@6.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + + date-format@4.0.14: + optional: true + + debug@2.6.9: + dependencies: + ms: 2.0.0 + optional: true + + debug@4.3.7: + dependencies: + ms: 2.1.3 + optional: true + + debug@4.4.0: + dependencies: + ms: 2.1.3 + optional: true + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: + optional: true + + detect-libc@1.0.3: + optional: true + + detect-libc@2.0.3: {} + + di@0.0.1: + optional: true + + dom-serialize@2.2.1: + dependencies: + custom-event: 1.0.1 + ent: 2.2.2 + extend: 3.0.2 + void-elements: 2.0.1 + optional: true + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.119: {} + + electron-to-chromium@1.5.262: {} + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@1.0.2: + optional: true + + encodeurl@2.0.0: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + engine.io-parser@5.2.3: + optional: true + + engine.io@6.6.4: + dependencies: + '@types/cors': 2.8.17 + '@types/node': 22.13.10 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + + enhanced-resolve@5.18.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + ent@2.2.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + punycode: 1.4.1 + safe-regex-test: 1.1.0 + optional: true + + entities@4.5.0: {} + + entities@6.0.0: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + err-code@2.0.3: {} + + errno@0.1.8: + dependencies: + prr: 1.0.1 + optional: true + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.25.9: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.9 + '@esbuild/android-arm': 0.25.9 + '@esbuild/android-arm64': 0.25.9 + '@esbuild/android-x64': 0.25.9 + '@esbuild/darwin-arm64': 0.25.9 + '@esbuild/darwin-x64': 0.25.9 + '@esbuild/freebsd-arm64': 0.25.9 + '@esbuild/freebsd-x64': 0.25.9 + '@esbuild/linux-arm': 0.25.9 + '@esbuild/linux-arm64': 0.25.9 + '@esbuild/linux-ia32': 0.25.9 + '@esbuild/linux-loong64': 0.25.9 + '@esbuild/linux-mips64el': 0.25.9 + '@esbuild/linux-ppc64': 0.25.9 + '@esbuild/linux-riscv64': 0.25.9 + '@esbuild/linux-s390x': 0.25.9 + '@esbuild/linux-x64': 0.25.9 + '@esbuild/netbsd-arm64': 0.25.9 + '@esbuild/netbsd-x64': 0.25.9 + '@esbuild/openbsd-arm64': 0.25.9 + '@esbuild/openbsd-x64': 0.25.9 + '@esbuild/openharmony-arm64': 0.25.9 + '@esbuild/sunos-x64': 0.25.9 + '@esbuild/win32-arm64': 0.25.9 + '@esbuild/win32-ia32': 0.25.9 + '@esbuild/win32-x64': 0.25.9 + + esbuild@0.26.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.26.0 + '@esbuild/android-arm': 0.26.0 + '@esbuild/android-arm64': 0.26.0 + '@esbuild/android-x64': 0.26.0 + '@esbuild/darwin-arm64': 0.26.0 + '@esbuild/darwin-x64': 0.26.0 + '@esbuild/freebsd-arm64': 0.26.0 + '@esbuild/freebsd-x64': 0.26.0 + '@esbuild/linux-arm': 0.26.0 + '@esbuild/linux-arm64': 0.26.0 + '@esbuild/linux-ia32': 0.26.0 + '@esbuild/linux-loong64': 0.26.0 + '@esbuild/linux-mips64el': 0.26.0 + '@esbuild/linux-ppc64': 0.26.0 + '@esbuild/linux-riscv64': 0.26.0 + '@esbuild/linux-s390x': 0.26.0 + '@esbuild/linux-x64': 0.26.0 + '@esbuild/netbsd-arm64': 0.26.0 + '@esbuild/netbsd-x64': 0.26.0 + '@esbuild/openbsd-arm64': 0.26.0 + '@esbuild/openbsd-x64': 0.26.0 + '@esbuild/openharmony-arm64': 0.26.0 + '@esbuild/sunos-x64': 0.26.0 + '@esbuild/win32-arm64': 0.26.0 + '@esbuild/win32-ia32': 0.26.0 + '@esbuild/win32-x64': 0.26.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + eventemitter3@4.0.7: + optional: true + + eventemitter3@5.0.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + expect-type@1.3.0: {} + + exponential-backoff@3.1.2: {} + + express-rate-limit@7.5.1(express@5.1.0): + dependencies: + express: 5.1.0 + + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.1 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-uri@3.0.6: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + optional: true + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + optional: true + + finalhandler@2.1.0: + dependencies: + debug: 4.4.1 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + flatted@3.3.3: + optional: true + + follow-redirects@1.15.9: + optional: true + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + forwarded@0.2.0: {} + + fraction.js@4.3.7: {} + + fresh@2.0.0: {} + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + optional: true + + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.2 + + fs.realpath@1.0.0: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.3.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + optional: true + + glob-to-regexp@0.4.1: {} + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.0: + dependencies: + minimatch: 10.1.1 + minipass: 7.1.2 + path-scurry: 2.0.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + optional: true + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + optional: true + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hosted-git-info@9.0.0: + dependencies: + lru-cache: 11.1.0 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.7.0 + transitivePeerDependencies: + - '@exodus/crypto' + + htmlparser2@10.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.0 + + http-cache-semantics@4.1.1: {} + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.3 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.9 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + optional: true + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + ignore-walk@8.0.0: + dependencies: + minimatch: 10.1.1 + + image-size@0.5.5: + optional: true + + immutable@5.0.3: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + optional: true + + inherits@2.0.4: {} + + ini@5.0.0: {} + + ini@6.0.0: {} + + ip-address@9.0.5: + dependencies: + jsbn: 1.1.0 + sprintf-js: 1.1.3 + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + optional: true + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: + optional: true + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.3.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + optional: true + + is-interactive@2.0.0: {} + + is-number@7.0.0: + optional: true + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + optional: true + + is-unicode-supported@2.1.0: {} + + is-what@3.14.1: + optional: true + + isbinaryfile@4.0.10: + optional: true + + isexe@2.0.0: {} + + isexe@3.1.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.4 + '@babel/parser': 7.28.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jasmine-core@5.1.2: {} + + jiti@2.4.2: {} + + jiti@2.6.1: + optional: true + + js-tokens@4.0.0: {} + + jsbn@1.1.0: {} + + jsdom@27.4.0: + dependencies: + '@acemir/cssom': 0.9.30 + '@asamuzakjp/dom-selector': 6.7.6 + '@exodus/bytes': 1.7.0 + cssstyle: 5.3.6 + data-urls: 6.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.0 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@exodus/crypto' + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-parse-even-better-errors@5.0.0: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + optional: true + + jsonparse@1.3.1: {} + + karma@6.4.4: + dependencies: + '@colors/colors': 1.5.0 + body-parser: 1.20.3 + braces: 3.0.3 + chokidar: 3.6.0 + connect: 3.7.0 + di: 0.0.1 + dom-serialize: 2.2.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + http-proxy: 1.18.1 + isbinaryfile: 4.0.10 + lodash: 4.17.21 + log4js: 6.9.1 + mime: 2.6.0 + minimatch: 3.1.2 + mkdirp: 0.5.6 + qjobs: 1.2.0 + range-parser: 1.2.1 + rimraf: 3.0.2 + socket.io: 4.8.1 + source-map: 0.6.1 + tmp: 0.2.3 + ua-parser-js: 0.7.40 + yargs: 16.2.0 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + optional: true + + less@4.4.2: + dependencies: + copy-anything: 2.0.6 + parse-node-version: 1.0.1 + tslib: 2.8.1 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.11 + image-size: 0.5.5 + make-dir: 2.1.0 + mime: 1.6.0 + needle: 3.3.1 + source-map: 0.6.1 + optional: true + + lightningcss-darwin-arm64@1.29.2: + optional: true + + lightningcss-darwin-x64@1.29.2: + optional: true + + lightningcss-freebsd-x64@1.29.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.29.2: + optional: true + + lightningcss-linux-arm64-gnu@1.29.2: + optional: true + + lightningcss-linux-arm64-musl@1.29.2: + optional: true + + lightningcss-linux-x64-gnu@1.29.2: + optional: true + + lightningcss-linux-x64-musl@1.29.2: + optional: true + + lightningcss-win32-arm64-msvc@1.29.2: + optional: true + + lightningcss-win32-x64-msvc@1.29.2: + optional: true + + lightningcss@1.29.2: + dependencies: + detect-libc: 2.0.3 + optionalDependencies: + lightningcss-darwin-arm64: 1.29.2 + lightningcss-darwin-x64: 1.29.2 + lightningcss-freebsd-x64: 1.29.2 + lightningcss-linux-arm-gnueabihf: 1.29.2 + lightningcss-linux-arm64-gnu: 1.29.2 + lightningcss-linux-arm64-musl: 1.29.2 + lightningcss-linux-x64-gnu: 1.29.2 + lightningcss-linux-x64-musl: 1.29.2 + lightningcss-win32-arm64-msvc: 1.29.2 + lightningcss-win32-x64-msvc: 1.29.2 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.1.1 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + lmdb@3.4.3: + dependencies: + msgpackr: 1.11.2 + node-addon-api: 6.1.0 + node-gyp-build-optional-packages: 5.2.2 + ordered-binary: 1.5.3 + weak-lru-cache: 1.2.2 + optionalDependencies: + '@lmdb/lmdb-darwin-arm64': 3.4.3 + '@lmdb/lmdb-darwin-x64': 3.4.3 + '@lmdb/lmdb-linux-arm': 3.4.3 + '@lmdb/lmdb-linux-arm64': 3.4.3 + '@lmdb/lmdb-linux-x64': 3.4.3 + '@lmdb/lmdb-win32-arm64': 3.4.3 + '@lmdb/lmdb-win32-x64': 3.4.3 + optional: true + + lodash@4.17.21: + optional: true + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + log4js@6.9.1: + dependencies: + date-format: 4.0.14 + debug: 4.4.0 + flatted: 3.3.3 + rfdc: 1.4.1 + streamroller: 3.1.5 + transitivePeerDependencies: + - supports-color + optional: true + + lru-cache@10.4.3: {} + + lru-cache@11.1.0: {} + + lru-cache@11.2.4: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + optional: true + + make-fetch-happen@15.0.3: + dependencies: + '@npmcli/agent': 4.0.0 + cacache: 20.0.3 + http-cache-semantics: 4.1.1 + minipass: 7.1.2 + minipass-fetch: 5.0.0 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 6.1.0 + promise-retry: 2.0.1 + ssri: 13.0.0 + transitivePeerDependencies: + - supports-color + + math-intrinsics@1.1.0: {} + + mdn-data@2.12.2: {} + + media-typer@0.3.0: + optional: true + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + optional: true + + mime-db@1.52.0: + optional: true + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + optional: true + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: + optional: true + + mime@2.6.0: + optional: true + + mimic-function@5.0.1: {} + + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + optional: true + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: + optional: true + + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.2 + + minipass-fetch@5.0.0: + dependencies: + minipass: 7.1.2 + minipass-sized: 1.0.3 + minizlib: 3.0.1 + optionalDependencies: + encoding: 0.1.13 + + minipass-flush@1.0.5: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@7.1.2: {} + + minizlib@3.0.1: + dependencies: + minipass: 7.1.2 + rimraf: 5.0.10 + + minizlib@3.1.0: + dependencies: + minipass: 7.1.2 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + optional: true + + mkdirp@3.0.1: {} + + mrmime@2.0.1: {} + + ms@2.0.0: + optional: true + + ms@2.1.3: {} + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.2: + optionalDependencies: + msgpackr-extract: 3.0.3 + optional: true + + mute-stream@2.0.0: {} + + nanoid@3.3.10: {} + + nanoid@3.3.11: {} + + needle@3.3.1: + dependencies: + iconv-lite: 0.6.3 + sax: 1.4.1 + optional: true + + negotiator@0.6.3: + optional: true + + negotiator@1.0.0: {} + + node-addon-api@6.1.0: + optional: true + + node-addon-api@7.1.1: + optional: true + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.0.3 + optional: true + + node-gyp@12.1.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.2 + graceful-fs: 4.2.11 + make-fetch-happen: 15.0.3 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.7.3 + tar: 7.5.2 + tinyglobby: 0.2.14 + which: 6.0.0 + transitivePeerDependencies: + - supports-color + + node-releases@2.0.19: {} + + node-releases@2.0.27: {} + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-path@3.0.0: + optional: true + + normalize-range@0.1.2: {} + + npm-bundled@4.0.0: + dependencies: + npm-normalize-package-bin: 4.0.0 + + npm-install-checks@8.0.0: + dependencies: + semver: 7.7.3 + + npm-normalize-package-bin@4.0.0: {} + + npm-normalize-package-bin@5.0.0: {} + + npm-package-arg@13.0.1: + dependencies: + hosted-git-info: 9.0.0 + proc-log: 5.0.0 + semver: 7.7.3 + validate-npm-package-name: 6.0.0 + + npm-packlist@10.0.3: + dependencies: + ignore-walk: 8.0.0 + proc-log: 6.1.0 + + npm-pick-manifest@11.0.3: + dependencies: + npm-install-checks: 8.0.0 + npm-normalize-package-bin: 5.0.0 + npm-package-arg: 13.0.1 + semver: 7.7.3 + + npm-registry-fetch@19.1.1: + dependencies: + '@npmcli/redact': 4.0.0 + jsonparse: 1.3.1 + make-fetch-happen: 15.0.3 + minipass: 7.1.2 + minipass-fetch: 5.0.0 + minizlib: 3.0.1 + npm-package-arg: 13.0.1 + proc-log: 6.1.0 + transitivePeerDependencies: + - supports-color + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.1.1: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + optional: true + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@9.0.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.3.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.2.2 + string-width: 8.1.0 + strip-ansi: 7.1.2 + + ordered-binary@1.5.3: + optional: true + + p-map@7.0.3: {} + + package-json-from-dist@1.0.1: {} + + pacote@21.0.3: + dependencies: + '@npmcli/git': 7.0.1 + '@npmcli/installed-package-contents': 3.0.0 + '@npmcli/package-json': 7.0.4 + '@npmcli/promise-spawn': 8.0.2 + '@npmcli/run-script': 10.0.3 + cacache: 20.0.3 + fs-minipass: 3.0.3 + minipass: 7.1.2 + npm-package-arg: 13.0.1 + npm-packlist: 10.0.3 + npm-pick-manifest: 11.0.3 + npm-registry-fetch: 19.1.1 + proc-log: 5.0.0 + promise-retry: 2.0.1 + sigstore: 4.0.0 + ssri: 12.0.0 + tar: 7.4.3 + transitivePeerDependencies: + - supports-color + + parse-node-version@1.0.1: + optional: true + + parse5-html-rewriting-stream@8.0.0: + dependencies: + entities: 6.0.0 + parse5: 8.0.0 + parse5-sax-parser: 8.0.0 + + parse5-sax-parser@8.0.0: + dependencies: + parse5: 8.0.0 + + parse5@8.0.0: + dependencies: + entities: 6.0.0 + + parseurl@1.3.3: {} + + path-is-absolute@1.0.1: + optional: true + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-scurry@2.0.1: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + + path-to-regexp@8.2.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: + optional: true + + picomatch@4.0.3: {} + + pify@4.0.1: + optional: true + + piscina@5.1.3: + optionalDependencies: + '@napi-rs/nice': 1.1.1 + + pkce-challenge@5.0.0: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.5.3: + dependencies: + nanoid: 3.3.10 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proc-log@5.0.0: {} + + proc-log@6.1.0: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + prr@1.0.1: + optional: true + + punycode@1.4.1: + optional: true + + punycode@2.3.1: {} + + qjobs@1.2.0: + optional: true + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + optional: true + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + optional: true + + raw-body@3.0.0: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + unpipe: 1.0.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + optional: true + + readdirp@4.1.2: {} + + reflect-metadata@0.2.2: {} + + require-directory@2.1.1: + optional: true + + require-from-string@2.0.2: {} + + requires-port@1.0.0: + optional: true + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.12.0: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + optional: true + + rimraf@5.0.10: + dependencies: + glob: 10.4.5 + + rolldown@1.0.0-beta.47: + dependencies: + '@oxc-project/types': 0.96.0 + '@rolldown/pluginutils': 1.0.0-beta.47 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.47 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.47 + '@rolldown/binding-darwin-x64': 1.0.0-beta.47 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.47 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.47 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.47 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.47 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.47 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.47 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.47 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.47 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.47 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.47 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.47 + + rollup@4.50.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.50.0 + '@rollup/rollup-android-arm64': 4.50.0 + '@rollup/rollup-darwin-arm64': 4.50.0 + '@rollup/rollup-darwin-x64': 4.50.0 + '@rollup/rollup-freebsd-arm64': 4.50.0 + '@rollup/rollup-freebsd-x64': 4.50.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.0 + '@rollup/rollup-linux-arm-musleabihf': 4.50.0 + '@rollup/rollup-linux-arm64-gnu': 4.50.0 + '@rollup/rollup-linux-arm64-musl': 4.50.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.50.0 + '@rollup/rollup-linux-ppc64-gnu': 4.50.0 + '@rollup/rollup-linux-riscv64-gnu': 4.50.0 + '@rollup/rollup-linux-riscv64-musl': 4.50.0 + '@rollup/rollup-linux-s390x-gnu': 4.50.0 + '@rollup/rollup-linux-x64-gnu': 4.50.0 + '@rollup/rollup-linux-x64-musl': 4.50.0 + '@rollup/rollup-openharmony-arm64': 4.50.0 + '@rollup/rollup-win32-arm64-msvc': 4.50.0 + '@rollup/rollup-win32-ia32-msvc': 4.50.0 + '@rollup/rollup-win32-x64-msvc': 4.50.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.1 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + optional: true + + safer-buffer@2.1.2: {} + + sass@1.93.2: + dependencies: + chokidar: 4.0.3 + immutable: 5.0.3 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + sax@1.4.1: + optional: true + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@5.7.2: + optional: true + + semver@6.3.1: {} + + semver@7.7.2: {} + + semver@7.7.3: {} + + send@1.2.0: + dependencies: + debug: 4.4.1 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.0 + mime-types: 3.0.1 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sigstore@4.0.0: + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.0.0 + '@sigstore/protobuf-specs': 0.5.0 + '@sigstore/sign': 4.0.1 + '@sigstore/tuf': 4.0.0 + '@sigstore/verify': 3.0.0 + transitivePeerDependencies: + - supports-color + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + smart-buffer@4.2.0: {} + + socket.io-adapter@2.5.5: + dependencies: + debug: 4.3.7 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + optional: true + + socket.io@4.8.1: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.7 + engine.io: 6.6.4 + socket.io-adapter: 2.5.5 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.3 + debug: 4.4.1 + socks: 2.8.4 + transitivePeerDependencies: + - supports-color + + socks@2.8.4: + dependencies: + ip-address: 9.0.5 + smart-buffer: 4.2.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.21 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.21 + + spdx-license-ids@3.0.21: {} + + sprintf-js@1.1.3: {} + + ssri@12.0.0: + dependencies: + minipass: 7.1.2 + + ssri@13.0.0: + dependencies: + minipass: 7.1.2 + + stackback@0.0.2: {} + + statuses@1.5.0: + optional: true + + statuses@2.0.1: {} + + std-env@3.10.0: {} + + stdin-discarder@0.2.2: {} + + streamroller@3.1.5: + dependencies: + date-format: 4.0.14 + debug: 4.4.0 + fs-extra: 8.1.0 + transitivePeerDependencies: + - supports-color + optional: true + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.1.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tailwindcss@4.1.4: {} + + tapable@2.2.1: {} + + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.1 + mkdirp: 3.0.1 + yallist: 5.0.0 + + tar@7.5.2: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.1.0 + yallist: 5.0.0 + + terser@5.44.0: + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.14: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + + tmp@0.2.3: + optional: true + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + optional: true + + toidentifier@1.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: {} + + tuf-js@4.0.0: + dependencies: + '@tufjs/models': 4.0.0 + debug: 4.4.1 + make-fetch-happen: 15.0.3 + transitivePeerDependencies: + - supports-color + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + optional: true + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + + typescript@5.9.3: {} + + ua-parser-js@0.7.40: + optional: true + + undici-types@6.20.0: + optional: true + + undici@7.16.0: {} + + unique-filename@5.0.0: + dependencies: + unique-slug: 6.0.0 + + unique-slug@6.0.0: + dependencies: + imurmurhash: 0.1.4 + + universalify@0.1.2: + optional: true + + unpipe@1.0.0: {} + + update-browserslist-db@1.1.3(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + update-browserslist-db@1.1.4(browserslist@4.28.0): + dependencies: + browserslist: 4.28.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + utils-merge@1.0.1: + optional: true + + uuid@11.1.0: {} + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validate-npm-package-name@6.0.0: {} + + vary@1.1.2: {} + + vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0): + dependencies: + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.50.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.13.10 + fsevents: 2.3.3 + jiti: 2.6.1 + less: 4.4.2 + lightningcss: 1.29.2 + sass: 1.93.2 + terser: 5.44.0 + yaml: 2.7.0 + + vitest@4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0): + dependencies: + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.13.10 + jsdom: 27.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + void-elements@2.0.1: + optional: true + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + watchpack@2.4.4: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + weak-lru-cache@1.2.2: + optional: true + + webidl-conversions@8.0.0: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.1 + + which@6.0.0: + dependencies: + isexe: 3.1.1 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + ws@8.17.1: + optional: true + + ws@8.18.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + + yaml@2.7.0: + optional: true + + yargs-parser@20.2.9: + optional: true + + yargs-parser@22.0.0: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + optional: true + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.24.6(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zone.js@0.15.0: + optional: true diff --git a/osse-web/src/app/albums/albums.component.css b/osse-web/src/app/albums/albums.component.css new file mode 100644 index 0000000..8ca5372 --- /dev/null +++ b/osse-web/src/app/albums/albums.component.css @@ -0,0 +1,6 @@ +/* On mobile, always show buttons */ +@media (hover: none) and (pointer: coarse) { + .touch-show { + opacity: 1 !important; + } +} diff --git a/osse-web/src/app/albums/albums.component.html b/osse-web/src/app/albums/albums.component.html new file mode 100644 index 0000000..5f75888 --- /dev/null +++ b/osse-web/src/app/albums/albums.component.html @@ -0,0 +1,55 @@ + +
+

{{loading() ? 'Loading' : albums().length}} Albums

+
+
+ + +
+
+
+@if (!loading()) { +
+ @for (album of filteredAlbums(); track $index) { + + } @empty { +
+

No Albums Exist...

+
+ } +
+} @else { +
+

Loading Albums

+
+} diff --git a/osse-web/src/app/albums/albums.component.ts b/osse-web/src/app/albums/albums.component.ts new file mode 100644 index 0000000..7e5878d --- /dev/null +++ b/osse-web/src/app/albums/albums.component.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, Component, OnInit, WritableSignal, signal } from '@angular/core'; +import { ConfigService } from '../shared/services/config/config.service'; +import { RouterLink } from '@angular/router'; +import { Album } from '../shared/services/album/Album'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { OsseAlbum } from '../shared/services/album/osse-album'; +import { fetcher } from '../shared/util/fetcher'; +import { IconComponent } from '../shared/ui/icon/icon.component'; +import { mdiPlaylistPlay, mdiSearchWeb } from '@mdi/js'; +import { TrackService } from '../shared/services/track/track.service'; +import { ToastService } from '../toast-container/toast.service'; + +@Component({ + selector: 'app-albums', + imports: [RouterLink, HeaderComponent, IconComponent], + templateUrl: './albums.component.html', + styleUrl: `./albums.component.css`, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AlbumsComponent implements OnInit { + albums: WritableSignal = signal([]); + filteredAlbums: WritableSignal = signal([]); + coverUrlBase: WritableSignal = signal(this.configService.get('apiURL') + "api/tracks/ID/cover"); + loading: WritableSignal = signal(true); + + search = mdiSearchWeb; + play = mdiPlaylistPlay; + + constructor( + private configService: ConfigService, + private trackService: TrackService, + private notificationService: ToastService + ) { } + + public filterAlbums(event: any) { + if (event.target.value.trim().length == 0) { + this.filteredAlbums.set(this.albums()); + } else { + const regex = new RegExp(event.target.value.trim(), 'i'); + this.filteredAlbums.set(this.albums().filter((a) => regex.test(a.name))); + } + } + + public playAlbum(id: number) { + let album = this.filteredAlbums().find((a) => a.id == id); + if (album) { + for (const track of album.tracks) { + this.trackService.addTrack(track); + } + + if (album.tracks.length > 1) { + this.notificationService.info(`Added ${album.tracks.length} tracks to queue.`); + } else { + this.notificationService.info(`Added track to queue.`); + } + } + } + + async ngOnInit(): Promise { + let request = await fetcher('albums?tracks=true'); + let result: OsseAlbum[] = await request.json(); + + result.sort((a, b) => a.name.localeCompare(b.name)); + this.albums.set(result.map((a) => new Album(a))); + this.filteredAlbums.set(this.albums()); + this.loading.set(false); + } +} diff --git a/osse-web/src/app/albums/view/album-filter.ts b/osse-web/src/app/albums/view/album-filter.ts new file mode 100644 index 0000000..f907002 --- /dev/null +++ b/osse-web/src/app/albums/view/album-filter.ts @@ -0,0 +1,7 @@ +export enum AlbumFilter { + Alphabetical, + TrackNumber, + DiscNumber, + Random, +} + diff --git a/osse-web/src/app/albums/view/album-view.resolver.ts b/osse-web/src/app/albums/view/album-view.resolver.ts new file mode 100644 index 0000000..c889dfc --- /dev/null +++ b/osse-web/src/app/albums/view/album-view.resolver.ts @@ -0,0 +1,21 @@ +import { ResolveFn } from '@angular/router'; +import { Album } from '../../shared/services/album/Album'; +import { fetcher } from '../../shared/util/fetcher'; +import { LoadingService } from '../../shared/ui/loading/loading.service'; +import { inject } from '@angular/core'; + +export const albumViewResolver: ResolveFn = async (route, _state) => { + let loadingService: LoadingService = inject(LoadingService); + loadingService.startLoading(); + let id = route.paramMap.get('id'); + + let request = await fetcher(`albums/${id}?tracks=true`); + if (request.ok) { + let album = await request.json(); + loadingService.endLoading(); + return new Album(album.data); + } + + loadingService.endLoading(); + throw "Not Found" +}; diff --git a/osse-web/src/app/albums/view/view.component.html b/osse-web/src/app/albums/view/view.component.html new file mode 100644 index 0000000..cb4c2c4 --- /dev/null +++ b/osse-web/src/app/albums/view/view.component.html @@ -0,0 +1,71 @@ +
+
+
+ +
+
+ +
+
+

+ {{album().artist[0]?.name ?? albumTrackArtist() ?? "Unknown Artist"}} +

+

{{totalDuration}} Minutes

+ @if (album().album.year) { +

{{album().album.year}}

+ } +
+
+ +
+
+
+
+

{{album().tracks.length}} Tracks

+
+
+ + +
+
+ + +
+
+
+ +
+ + + +
+
+ +
+
+
diff --git a/osse-web/src/app/albums/view/view.component.ts b/osse-web/src/app/albums/view/view.component.ts new file mode 100644 index 0000000..7190278 --- /dev/null +++ b/osse-web/src/app/albums/view/view.component.ts @@ -0,0 +1,201 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, WritableSignal, signal } from '@angular/core'; +import { Album } from '../../shared/services/album/Album'; +import { ConfigService } from '../../shared/services/config/config.service'; +import { TrackService } from '../../shared/services/track/track.service'; +import { Track } from '../../shared/services/track/track'; +import { HeaderComponent } from '../../shared/ui/header/header.component'; +import { ToastService } from '../../toast-container/toast.service'; +import { BackgroundImageService } from '../../shared/ui/background-image.service'; +import { IconComponent } from '../../shared/ui/icon/icon.component'; +import { mdiClose, mdiFilter, mdiPencil, mdiPlaylistPlay, mdiSearchWeb } from '@mdi/js'; +import { AlbumFilter } from './album-filter'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { ModalService } from '../../shared/ui/modal/modal.service'; +import { AlbumArtFullscreenComponent } from '../../shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component'; +import { TrackMatrixComponent } from '../../shared/ui/track-matrix/track-matrix.component'; +import { CommonModule } from '@angular/common'; +import { TrackMatrixMode } from '../../shared/ui/track-matrix/track-matrix-mode.enum'; +import { AddMultipleTracksToPlaylistComponent } from '../../shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component'; +import { Subscription } from 'rxjs'; +import { TrackInfo } from '../../shared/ui/track-matrix/track-info'; + +@Component({ + selector: 'app-view', + imports: [HeaderComponent, IconComponent, FormsModule, TrackMatrixComponent, CommonModule], + templateUrl: './view.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ViewComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild(TrackMatrixComponent) matrix!: TrackMatrixComponent; + public album!: WritableSignal; + public filteredTracks: WritableSignal = signal([]); + public filterType = AlbumFilter; + public chosenFilter: WritableSignal = signal(AlbumFilter.TrackNumber); + public albumTrackArtist: WritableSignal = signal(''); + public bg = signal(''); + public duration = 0; + public editing: WritableSignal = signal(false); + + search = mdiSearchWeb; + filter = mdiFilter; + pencil = mdiPencil; + close = mdiClose; + play = mdiPlaylistPlay; + + private modalSubscription!: Subscription; + + constructor( + private configService: ConfigService, + private trackService: TrackService, + private notificationService: ToastService, + private backgroundImageService: BackgroundImageService, + private activatedRoute: ActivatedRoute, + private modalService: ModalService + ) { } + + public addAll() { + this.album().tracks.forEach((t) => this.trackService.addTrack(t)); + this.notificationService.info('Added ' + this.album().tracks.length + ' tracks'); + } + + public addTrack(track: Track) { + this.trackService.addTrack(track); + this.notificationService.info('Added ' + track.title); + } + + public get totalDuration() { + let total = 0; + + this.album().tracks.forEach(t => { + total += t.duration; + }); + + return Math.floor(total / 60); + } + + public filterTracks(event: any) { + if (event.target.value.trim().length == 0) { + this.filteredTracks.set(this.album().tracks); + } else { + const regex = new RegExp(event.target.value, 'i'); + this.filteredTracks.set(this.album().tracks.filter((t) => regex.test(t.title))); + } + } + + public sortTracks() { + if (this.chosenFilter() == AlbumFilter.Alphabetical) { + this.filteredTracks().sort((a, b) => { + if (a.title.toLowerCase() < b.title.toLowerCase()) { + return -1; + } + if (a.title.toLowerCase() > b.title.toLowerCase()) { + return 1; + } + return 0; + }); + } else if (this.chosenFilter() == AlbumFilter.Random) { + this.filteredTracks.update(value => { + return value.map(value => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value) + }); + } else if (this.chosenFilter() == AlbumFilter.DiscNumber) { + this.filteredTracks.update(v => v.sort((a, b) => (a.discNumber ?? 0) - (b.discNumber ?? 0))); + // To-do: When we store the total discs, sort by disc order and show title + } else { + // Sort by track number + this.filteredTracks.update(v => v.sort((a, b) => (a.trackNumber ?? 0) - (b.trackNumber ?? 0))); + } + } + + public async artistFromTracks() { + let artists = new Map(); + for (let i = 0; i < this.album().tracks.length; i++) { + let track = this.album().tracks[i]; + if (track.artistPrimary() != null) { + artists.set(i, (artists.get(track.artistPrimary()?.id) ?? 0) + 1); + } + } + + if (artists.size == 0) return; + + // Get the artist with the highest track occurence and set it to the album artist + let artist = ([...artists.entries()]).reduce((a, e) => e[1] > a[1] ? e : a); + await this.album().tracks[artist[0]].getArtist(); + this.albumTrackArtist.set((this.album().tracks[artist[0]].artistPrimary())?.name + ' (Inferred)'); + } + + public showAlbumArt() { + let url = this.album().tracks[0]?.coverURL; + + this.modalService.setDynamicModal(AlbumArtFullscreenComponent, [{ + name: 'url', + val: url + }], 'Album Art'); + this.modalService.show(); + } + + public handleModeChange(mode: TrackMatrixMode) { + if (mode == TrackMatrixMode.Select) { + this.editing.set(true); + } else { + this.editing.set(false); + } + } + + public handleEmptySelection() { + this.matrix.setMode(TrackMatrixMode.View); + } + + /** + * Removes all selected tracks. + */ + public clearSelectedTracks() { + this.matrix.clearSelectedTracks(); + } + + public playSelectedTracks() { + let tracks = this.matrix.getSelectedTracks(); + for (const track of tracks) { + this.trackService.addTrack(track); + } + + this.notificationService.info('Added ' + tracks.length + ' tracks.'); + } + + public addSelectedTracksToPlaylist() { + this.modalService.setDynamicModal(AddMultipleTracksToPlaylistComponent, [{ + name: 'tracks', + val: this.matrix.getSelectedTracks() + }], 'Add to Playlist'); + this.modalService.show(); + } + + ngOnInit(): void { + this.album = signal(this.activatedRoute.snapshot.data['album']); + this.filteredTracks.set(this.album().tracks); + this.bg.set(this.configService.get('apiURL') + "api/tracks/" + (this.album().tracks[0]?.id ?? -1) + '/cover') + + this.backgroundImageService.setBG(this.bg()); + + this.sortTracks(); + this.artistFromTracks(); + + this.duration = this.album().tracks.reduce((acc, t) => t.duration + acc, 0) % 60; + + this.modalSubscription = this.modalService.onClose.subscribe((_) => { + this.clearSelectedTracks(); + }); + } + + ngAfterViewInit(): void { + this.matrix.setVisibleFields(TrackInfo.allFields()); + } + + public ngOnDestroy(): void { + this.modalSubscription.unsubscribe(); + } +} + diff --git a/osse-web/src/app/app.component.css b/osse-web/src/app/app.component.css new file mode 100644 index 0000000..ba5eff5 --- /dev/null +++ b/osse-web/src/app/app.component.css @@ -0,0 +1,19 @@ +#outlet { + --bg: url('#'); +} + +#outlet::before { + content: ""; + position: fixed; + margin-bottom: 20rem; + top: 0; + left: 0; + width: 100%; + height: 100%; + filter: blur(8px) opacity(0.1); + background-repeat: no-repeat; + background-size: cover; + background-attachment: fixed; + z-index: -1; + background-image: var(--bg); +} diff --git a/osse-web/src/app/app.component.html b/osse-web/src/app/app.component.html new file mode 100644 index 0000000..2515e0d --- /dev/null +++ b/osse-web/src/app/app.component.html @@ -0,0 +1,17 @@ + + +
+ +
+
+
+ +
+
+
+ +
+ + diff --git a/osse-web/src/app/app.component.spec.ts b/osse-web/src/app/app.component.spec.ts new file mode 100644 index 0000000..2cd412f --- /dev/null +++ b/osse-web/src/app/app.component.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import { WebAudioService } from './shared/player/web-audio.service'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; +import { By } from '@angular/platform-browser'; +import { LoadingComponent } from './shared/ui/loading/loading.component'; +import { NavigationComponent } from './navigation/navigation.component'; +import { ToastContainerComponent } from './toast-container/toast-container.component'; +import { PlayerComponent } from './shared/player/player.component'; +import { ModalComponent } from './shared/ui/modal/modal.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideRouter(routes), + { + provide: WebAudioService, + useClass: class { + setUp() {}; + setPan(p: number) {}; + getPanValue() { + return 0.5; + } + } + } + ] + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have the 'osse' title`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('Osse'); + }); + + it('should have child components', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.debugElement; + + expect(compiled.query(By.directive(LoadingComponent))).toBeTruthy(); + expect(compiled.query(By.directive(NavigationComponent))).toBeTruthy(); + expect(compiled.query(By.directive(ToastContainerComponent))).toBeTruthy(); + expect(compiled.query(By.directive(PlayerComponent))).toBeTruthy(); + expect(compiled.query(By.directive(ModalComponent))).toBeTruthy(); + }); +}); diff --git a/osse-web/src/app/app.component.ts b/osse-web/src/app/app.component.ts new file mode 100644 index 0000000..2895a98 --- /dev/null +++ b/osse-web/src/app/app.component.ts @@ -0,0 +1,49 @@ +import { Component, ElementRef, Injector, signal, ViewChild, WritableSignal } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { NavigationComponent } from './navigation/navigation.component'; +import { PlayerComponent } from './shared/player/player.component'; +import { ToastContainerComponent } from './toast-container/toast-container.component'; +import { BackgroundImageService } from './shared/ui/background-image.service'; +import { PlayerService } from './shared/player/player.service'; +import { PlaybackState } from './shared/player/state-change'; +import { ModalComponent } from './shared/ui/modal/modal.component'; +import { LocatorService } from './locator.service'; +import { AuthService } from './shared/services/auth/auth.service'; +import { CommonModule } from '@angular/common'; +import { LoadingComponent } from './shared/ui/loading/loading.component'; +import { NetworkService } from './shared/services/network/network.service'; +import { PreloadService } from './shared/player/preload/preload.service'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, NavigationComponent, PlayerComponent, ToastContainerComponent, ModalComponent, LoadingComponent, CommonModule], + templateUrl: './app.component.html', + styleUrl: './app.component.css' +}) +export class AppComponent { + title = 'Osse'; + playerState: WritableSignal = signal(false); + showPlayer: WritableSignal = signal(false); + @ViewChild('outlet') outlet!: ElementRef; + + constructor( + private backgroundImageService: BackgroundImageService, + private playerService: PlayerService, + private injector: Injector, + private networkService: NetworkService, + private authService: AuthService, + private preloadService: PreloadService + ) { + // Allow shared injector for accessing services + LocatorService.injector = this.injector; + + this.backgroundImageService.bgChanged.subscribe((v) => { + this.outlet.nativeElement.style.setProperty('--bg', `url('${v}')`); + }); + this.playerService.stateChanged.subscribe((s) => { + this.playerState.set(s == PlaybackState.Playing); + }); + this.authService.authStateChanged.subscribe((v) => this.showPlayer.set(v)); + } +} + diff --git a/osse-web/src/app/app.config.ts b/osse-web/src/app/app.config.ts new file mode 100644 index 0000000..068b722 --- /dev/null +++ b/osse-web/src/app/app.config.ts @@ -0,0 +1,18 @@ +import { ApplicationConfig } from '@angular/core'; +import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router'; + +import { routes } from './app.routes'; +import { ConfigService } from './shared/services/config/config.service'; +import { TrackService } from './shared/services/track/track.service'; +import { AuthService } from './shared/services/auth/auth.service'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes, withComponentInputBinding(), withInMemoryScrolling({ + scrollPositionRestoration: "top" + })), + { provide: ConfigService }, + { provide: TrackService }, + { provide: AuthService } + ] +} diff --git a/osse-web/src/app/app.routes.ts b/osse-web/src/app/app.routes.ts new file mode 100644 index 0000000..98fc0cf --- /dev/null +++ b/osse-web/src/app/app.routes.ts @@ -0,0 +1,67 @@ +import { Routes } from '@angular/router'; +import { HomeComponent } from './home/home.component'; +import { albumViewResolver } from './albums/view/album-view.resolver'; +import { LoginComponent } from './login/login.component'; +import { isLoggedIn } from './shared/services/auth/auth.guard'; + +export const routes: Routes = [ + { + path: 'tracks', + loadComponent: () => import('./track-list/track-list.component').then(c => c.TrackListComponent), + canActivate: [isLoggedIn], + title: 'Osse - Track Search' + }, + { + path: 'albums', + loadComponent: () => import('./albums/albums.component').then(c => c.AlbumsComponent), + canActivate: [isLoggedIn], + title: 'Osse - Albums' + }, + { + path: 'albums/view/:id', + loadComponent: () => import('./albums/view/view.component').then(c => c.ViewComponent), + resolve: { + album: albumViewResolver + }, + canActivate: [isLoggedIn], + title: 'Osse - Albums' + }, + { + path: 'playlists', + loadComponent: () => import('./playlist/playlist.component').then(c => c.PlaylistComponent), + canActivate: [isLoggedIn], + title: 'Osse - Playlists' + }, + { + path: 'playlists/view/:id', + loadComponent: () => import('./playlist/playlist-view/playlist-view.component').then(c => c.PlaylistViewComponent), + canActivate: [isLoggedIn], + title: 'Osse - Playlists' + }, + { + path: 'settings', + loadComponent: () => import('./settings/settings.component').then(c => c.SettingsComponent), + canActivate: [isLoggedIn], + title: 'Osse - Settings' + }, + { + path: 'home', + component: HomeComponent, + canActivate: [isLoggedIn], + title: 'Osse - Player' + }, + { + path: 'login', + component: LoginComponent, + title: 'Osse - Login' + }, + { + path: 'register', + loadComponent: () => import('./registration/registration.component').then(c => c.RegistrationComponent), + title: 'Osse - Register' + }, + { + path: "**", + redirectTo: "home", + } +]; diff --git a/osse-web/src/app/home/home.component.html b/osse-web/src/app/home/home.component.html new file mode 100644 index 0000000..df74ed5 --- /dev/null +++ b/osse-web/src/app/home/home.component.html @@ -0,0 +1,62 @@ +
+
+ + +
+ +

{{ artist() || 'Unknown Artist' }}

+ +
+ @if (showVisualizer()) { + + } +
+
+
+
+
+
+
+ @if (tracksCanBeRestored()) { + + } @else { + + } + + + + + +
+
+
+
+ +@if (tracks().length > 0) { +
+ @for (track of tracks(); track track.uuid; let idx = $index) { + + } +
+} diff --git a/osse-web/src/app/home/home.component.ts b/osse-web/src/app/home/home.component.ts new file mode 100644 index 0000000..b61f3fa --- /dev/null +++ b/osse-web/src/app/home/home.component.ts @@ -0,0 +1,219 @@ +import { Component, computed, OnDestroy, OnInit, signal, WritableSignal } from '@angular/core'; +import { TrackComponent } from './track/track.component'; +import { TrackService } from '../shared/services/track/track.service'; +import { Track } from '../shared/services/track/track'; +import { PlayerService } from '../shared/player/player.service'; +import { PlaybackState } from '../shared/player/state-change'; +import { ConfigService } from '../shared/services/config/config.service'; +import { IconComponent } from '../shared/ui/icon/icon.component'; +import { mdiFastForward, mdiInformation, mdiRepeat, mdiRewind, mdiShuffle, mdiSilverwareForkKnife, mdiDeleteSweep, mdiRepeatOff, mdiRepeatOnce, mdiRestore, mdiCog } from '@mdi/js'; +import { ModalService } from '../shared/ui/modal/modal.service'; + +import { Subscription } from 'rxjs'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { AlbumArtFullscreenComponent } from '../shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component'; +import { ToastService } from '../toast-container/toast.service'; +import { TrackInfoComponent } from '../shared/ui/modals/track-info/track-info.component'; +import { Repeat } from '../shared/services/track/repeat.enum'; +import { VisualizerComponent } from '../shared/player/visualizer/visualizer.component'; +import { PlayerSettingsComponent } from '../shared/ui/modals/player-settings/player-settings.component'; +import { CommonModule } from '@angular/common'; +import { AddToPlaylistFactoryComponent } from '../shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component'; + +@Component({ + selector: 'app-home', + imports: [CommonModule, IconComponent, TrackComponent, HeaderComponent, VisualizerComponent], + templateUrl: './home.component.html' +}) +export class HomeComponent implements OnInit, OnDestroy { + public bg: WritableSignal = signal("assets/img/osse.webp"); + public tracks: WritableSignal = signal([]); + public playing: boolean = false; + public title: WritableSignal = signal('No Track Found'); + public artist: WritableSignal = signal(''); + public repeat = computed(() => { + switch (this.trackService.repeat()) { + case Repeat.None: return mdiRepeatOff; + case Repeat.Once: return mdiRepeatOnce; + case Repeat.Loop: return mdiRepeat; + } + }); + public repeatActive = computed(() => { + let repeat = this.trackService.repeat(); + return repeat == Repeat.Once || repeat == Repeat.Loop; + }); + public repeatTooltip = computed(() => { + switch (this.trackService.repeat()) { + case Repeat.None: return 'Repeat Off'; + case Repeat.Once: return 'Repeat Once'; + case Repeat.Loop: return 'Repeat Until Stopped'; + } + }); + public tracksCanBeRestored: WritableSignal = signal(false); + public showVisualizer: WritableSignal = signal(true); + + private trackUpdated!: Subscription; + private playbackEnded!: Subscription; + private stateChanged!: Subscription; + + forward = mdiFastForward; + back = mdiRewind; + shuffle = mdiShuffle; + info = mdiInformation; + consume = mdiSilverwareForkKnife; + clear = mdiDeleteSweep; + restore = mdiRestore; + settings = mdiCog; + + constructor( + public trackService: TrackService, + private playerService: PlayerService, + private configService: ConfigService, + private modalService: ModalService, + private notificationService: ToastService + ) { } + + public onPlayerToggle() { + // If no track, don't respond to button click + if (!this.trackService.activeTrack) return; + this.playing = !this.playing; + + if (!this.playing) { + this.playerService.pause(); + return; + } else { + this.playerService.play(); + } + } + + public onNextTrack() { + this.trackService.moveToNextTrack(); + } + + public onPreviousTrack() { + this.trackService.moveToLastTrack(); + } + + public playTrack(index: number) { + this.trackService.moveToTrack(index); + } + + public removeTrack(index: number) { + this.trackService.removeTrack(index); + } + + public addToPlaylist(track: Track) { + this.modalService.setDynamicModal(AddToPlaylistFactoryComponent, [{ + name: 'tracks', + val: [track] + }], 'Add to Playlist'); + this.modalService.show(); + } + + public shuffleTracks() { + this.trackService.shuffle(); + this.tracks.set(this.trackService.tracks); + } + + public toggleConsume() { + this.trackService.consume.update((v) => !v); + } + + public toggleRepeat() { + this.trackService.repeat.update((v) => { + switch (v) { + case Repeat.None: return Repeat.Once; + case Repeat.Once: return Repeat.Loop; + case Repeat.Loop: return Repeat.None; + } + }); + } + + public clearTracks() { + this.trackService.clearTracks(); + this.tracksCanBeRestored.set(true); + } + + public restoreTracks() { + this.trackService.restoreTracks(); + this.tracksCanBeRestored.set(false); + } + + public showAlbumArt() { + if (this.trackService.activeTrack) { + let url = this.trackService.activeTrack?.coverURL; + + this.modalService.setDynamicModal(AlbumArtFullscreenComponent, [{ + name: 'url', + val: url + }], 'Album Art'); + this.modalService.show(); + } else { + this.notificationService.info('You must have a track playing to view album art.'); + } + } + + public get consumeState() { + return this.trackService.consume.asReadonly(); + } + + showTrackInfo() { + if (this.trackService.activeTrack) { + this.modalService.setDynamicModal(TrackInfoComponent, [{ + name: 'trackInfo', + val: this.trackService.activeTrack + }], 'Track Info'); + this.modalService.show(); + } else { + this.notificationService.info('You must have a track playing to view track info.'); + } + } + + public showPlayerSettings() { + this.modalService.setDynamicModal(PlayerSettingsComponent, [{ + 'name': 'visualizerSignal', + 'val': this.showVisualizer + }], 'Player Settings'); + this.modalService.show(); + } + + ngOnInit(): void { + this.tracks.set(this.trackService.tracks); + + this.trackUpdated = this.playerService.trackUpdated.subscribe((val) => { + this.title.set(val.title); + this.artist.set(val.artist?.name || ''); + this.bg.set(val.cover); + }); + this.stateChanged = this.playerService.stateChanged.subscribe((val) => { + if (val == PlaybackState.Paused) { + this.playing = false; + } else { + this.playing = true; + } + }); + this.playbackEnded = this.playerService.playbackEnded.subscribe(_ => { + this.title.set(''); + this.artist.set(''); + this.bg.set("assets/img/osse.webp"); + this.playing = false; + }); + + // Get the initial value of the current track + if (this.trackService.activeTrack) { + this.title.set(this.trackService.activeTrack.title); + this.artist.set(this.trackService.activeTrack.artistPrimary()?.name ?? ''); + this.bg.set(this.trackService.activeTrack.coverURL); + } + + this.showVisualizer.set(this.configService.get('showVisualizer')); + } + + ngOnDestroy(): void { + this.trackUpdated.unsubscribe(); + this.stateChanged.unsubscribe(); + this.playbackEnded.unsubscribe(); + + this.trackService.removeClearedTracks(); + } +} diff --git a/osse-web/src/app/home/track/track.component.html b/osse-web/src/app/home/track/track.component.html new file mode 100644 index 0000000..e0f4dd1 --- /dev/null +++ b/osse-web/src/app/home/track/track.component.html @@ -0,0 +1,46 @@ +
+ @if (mode == 'view') { +
+ +
+
+
+ +
+
+ } @else { +
+ + + + +
+ } +
diff --git a/osse-web/src/app/home/track/track.component.ts b/osse-web/src/app/home/track/track.component.ts new file mode 100644 index 0000000..d9983ca --- /dev/null +++ b/osse-web/src/app/home/track/track.component.ts @@ -0,0 +1,55 @@ +import { Component, EventEmitter, input, Input, InputSignal, Output } from '@angular/core'; +import { Track } from '../../shared/services/track/track'; +import { IconComponent } from '../../shared/ui/icon/icon.component'; +import { mdiClose, mdiDotsVertical, mdiPlay, mdiPlaylistPlus, mdiStar, mdiTrashCan } from '@mdi/js'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-track', + imports: [IconComponent, CommonModule], + templateUrl: './track.component.html' +}) +export class TrackComponent { + @Input() track!: Track; + public activeTrack: InputSignal = input(false); + @Output() onPlay = new EventEmitter(); + @Output() onRemove = new EventEmitter(); + @Output() onPlaylistAdd = new EventEmitter(); + public mode: string = 'view'; + + star = mdiStar; + dots = mdiDotsVertical; + trash = mdiTrashCan; + cancel = mdiClose; + playlist = mdiPlaylistPlus; + play = mdiPlay; + + public toggleView() { + if (this.mode == 'view') { + this.mode = 'act'; + } else { + this.mode = 'view'; + } + } + + public toggleViewWithEvent(ev: Event) { + ev.preventDefault(); + this.toggleView(); + } + + public removeTrack() { + this.onRemove.emit(); + this.toggleView(); + } + + public addToPlaylist() { + this.onPlaylistAdd.emit(this.track); + this.toggleView(); + } + + public playTrack() { + this.onPlay.emit(); + this.toggleView(); + } +} + diff --git a/osse-web/src/app/locator.service.ts b/osse-web/src/app/locator.service.ts new file mode 100644 index 0000000..57b4c81 --- /dev/null +++ b/osse-web/src/app/locator.service.ts @@ -0,0 +1,12 @@ +import { Injectable, Injector } from '@angular/core'; + +/** + * A service that provides services to non angular services/components + */ +@Injectable({ + providedIn: 'root' +}) +export class LocatorService { + public static injector: Injector; + constructor() { } +} diff --git a/osse-web/src/app/login/login.component.html b/osse-web/src/app/login/login.component.html new file mode 100644 index 0000000..cf24986 --- /dev/null +++ b/osse-web/src/app/login/login.component.html @@ -0,0 +1,72 @@ +
+ + +
+

Welcome to the Osse music server!

+ + @if (showConnectionInputs()) { +

To begin, enter the server URL. This should include the host and port. The default URL has + been filled in for you.

+
+
+ + +
+ +
+ + +
+ +
+ +
+
+ } + + @if (serverFound()) { +

Please login.

+ +
+
+ + +
+ +
+ + +
+ +
+ + @if (waitingForResponse()) { + + } +
+
+ } + + @if (!serverFound() && !showConnectionInputs()) { +

Connecting to API...

+ } + + @if (serverFound() || showConnectionInputs()) { +

Don't have an account yet? Create One

+ } +
+
diff --git a/osse-web/src/app/login/login.component.ts b/osse-web/src/app/login/login.component.ts new file mode 100644 index 0000000..7337a14 --- /dev/null +++ b/osse-web/src/app/login/login.component.ts @@ -0,0 +1,94 @@ +import { Component, OnInit, signal, WritableSignal } from '@angular/core'; +import { fetcher } from '../shared/util/fetcher'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { FormsModule } from '@angular/forms'; +import { ToastService } from '../toast-container/toast.service'; +import { ConfigService } from '../shared/services/config/config.service'; +import { Router } from '@angular/router'; +import { AuthService } from '../shared/services/auth/auth.service'; + +@Component({ + selector: 'app-login', + imports: [HeaderComponent, FormsModule], + templateUrl: './login.component.html', + styles: `` +}) +export class LoginComponent implements OnInit { + public username: string = ''; + public password: string = ''; + public serverFound: WritableSignal = signal(false); + public url: WritableSignal = signal(window.location.hostname + ':8000'); + public protocol: WritableSignal = signal('http://'); + public showConnectionInputs: WritableSignal = signal(false); + public waitingForResponse = signal(false); + + constructor( + private notificationService: ToastService, + private configService: ConfigService, + private router: Router, + private authService: AuthService + ) { } + + public async saveURL() { + if (!this.url().endsWith("/")) { + this.url.update(u => u + "/"); + } + + // Check if the server URL is right. + try { + this.waitingForResponse.set(true); + await fetch(this.protocol().concat(this.url()) + 'api/ping', { + credentials: 'include' + }); + // Save the URL + this.configService.save("apiURL", this.protocol().concat(this.url())); + this.notificationService.info("URL saved as " + this.configService.get("apiURL")); + this.serverFound.set(true); + } catch (e) { + this.notificationService.error('Failed to reach server. Confirm that the URL is correct.'); + } finally { + this.waitingForResponse.set(false); + } + } + + public async login() { + if (this.username.length == 0 || this.password.length == 0) { + this.notificationService.error('You must enter a username and password.'); + return; + } + + this.waitingForResponse.set(true); + + let res = await fetcher('login', { + method: 'POST', + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + rootURL: this.configService.get('apiURL') + }); + + if (res.ok) { + await this.authService.attemptLogin(); + this.router.navigateByUrl('/home'); + } else { + this.notificationService.error('Login error. Check that the username and password are correct.'); + } + + this.waitingForResponse.set(false); + } + + async ngOnInit() { + // Try to login with the default URL. + try { + await fetch(this.configService.get('apiURL') + 'api/ping', { + credentials: 'include' + }); + this.serverFound.set(true); + } catch (e) { + // This should only happen in dev. If it fails, show the server URL inputs. + this.notificationService.error('Failed to autodetect server URL. Please enter it.'); + this.showConnectionInputs.set(true); + } + } +} diff --git a/osse-web/src/app/navigation/navigation.component.html b/osse-web/src/app/navigation/navigation.component.html new file mode 100644 index 0000000..b47ad98 --- /dev/null +++ b/osse-web/src/app/navigation/navigation.component.html @@ -0,0 +1,46 @@ +
+ +
+ + + + @if (showLogoutButton()) { + + } +
+ +
diff --git a/osse-web/src/app/navigation/navigation.component.ts b/osse-web/src/app/navigation/navigation.component.ts new file mode 100644 index 0000000..3589257 --- /dev/null +++ b/osse-web/src/app/navigation/navigation.component.ts @@ -0,0 +1,41 @@ +import { Component, computed, OnInit, signal, WritableSignal } from '@angular/core'; +import { Router, RouterLink, RouterLinkActive } from '@angular/router'; +import { IconComponent } from '../shared/ui/icon/icon.component'; +import { mdiCog, mdiHome, mdiLogout, mdiMenu, mdiMenuClose } from '@mdi/js'; +import { CommonModule } from '@angular/common'; +import { AuthService } from '../shared/services/auth/auth.service'; +import { ToastService } from '../toast-container/toast.service'; + + +@Component({ + selector: 'app-navigation', + imports: [RouterLink, RouterLinkActive, IconComponent, CommonModule], + templateUrl: './navigation.component.html', + styles: `` +}) +export class NavigationComponent implements OnInit { + gear = mdiCog; + home = mdiHome; + exit = mdiLogout; + mobileMenuOpen: WritableSignal = signal(false); + mobileMenuIcon = computed(() => this.mobileMenuOpen() ? mdiMenuClose : mdiMenu); + showLogoutButton: WritableSignal = signal(false); + + constructor(public authService: AuthService, private router: Router, private notificationService: ToastService) { } + + public toggleMenu() { + this.mobileMenuOpen.update((v) => !v); + } + + public async logout() { + await this.authService.logout(); + this.notificationService.info('Logged out successfully. Have a nice day!'); + this.router.navigateByUrl('login'); + } + + async ngOnInit() { + this.authService.authStateChanged.subscribe((v) => { + this.showLogoutButton.set(v); + }); + } +} diff --git a/osse-web/src/app/playlist/playlist-view/editPlaylistModel.ts b/osse-web/src/app/playlist/playlist-view/editPlaylistModel.ts new file mode 100644 index 0000000..5a17c0f --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/editPlaylistModel.ts @@ -0,0 +1,3 @@ +export class EditPlaylist { + constructor(public name: string) {} +} \ No newline at end of file diff --git a/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.html b/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.html new file mode 100644 index 0000000..aae1c05 --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.html @@ -0,0 +1,27 @@ + + +
+
+
+ +
+
+
+

Select a track to add it to the selection. Add it to the playlist when you have selected all the + tracks you want.

+
+
+ + +
+ + +
diff --git a/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.ts b/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.ts new file mode 100644 index 0000000..3c384cf --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.ts @@ -0,0 +1,143 @@ +import { Component, ElementRef, input, output, signal, ViewChild, WritableSignal } from '@angular/core'; +import { Track } from '../../../shared/services/track/track'; +import { TrackMatrixComponent } from '../../../shared/ui/track-matrix/track-matrix.component'; +import { HeaderComponent } from '../../../shared/ui/header/header.component'; +import { CommonModule } from '@angular/common'; +import { IconComponent } from '../../../shared/ui/icon/icon.component'; +import { debounceTime, fromEvent, Subscription } from 'rxjs'; +import { TrackMatrixMode } from '../../../shared/ui/track-matrix/track-matrix-mode.enum'; +import { fetcher } from '../../../shared/util/fetcher'; +import { mdiClose, mdiPlaylistPlay } from '@mdi/js'; + +@Component({ + selector: 'app-playlist-add-tracks', + imports: [CommonModule, TrackMatrixComponent, HeaderComponent, IconComponent], + templateUrl: './playlist-add-tracks.component.html', + styles: `` +}) +export class PlaylistAddTracksComponent { + @ViewChild('search') searchBar!: ElementRef; + @ViewChild(TrackMatrixComponent) matrix!: TrackMatrixComponent; + + public playlistID = input.required({}); + public addTracks = output(); + public loading: WritableSignal = signal(true); + public waitingOnRequest: WritableSignal = signal(false); + public tracks: WritableSignal = signal([]); + private allTracks: Track[] = []; + private timeout: number = 0; + private scrollSubscription!: Subscription; + + playlist = mdiPlaylistPlay; + close = mdiClose; + + constructor() { } + + ngAfterViewInit(): void { + this.matrix.setMode(TrackMatrixMode.Select); + } + + async ngOnInit(): Promise { + // Listen for scroll events + this.scrollSubscription = fromEvent(window, 'scroll') + .pipe(debounceTime(300)) + .subscribe(() => { + const endOfPage = window.innerHeight + window.pageYOffset >= (document.body.offsetHeight * 0.6); + if (endOfPage) { + this.requestTracks(this.searchBar.nativeElement.value); + } + }) + + // On load, get the first 75 tracks + let req = await fetcher('tracks/search'); + this.loading.set(false); + if (!req.ok) return; + + let tracks = await req.json(); + tracks.forEach((track: any) => { + this.allTracks.push(new Track(track)); + }); + this.tracks.set(this.allTracks); + } + + public async addSelectedTracksToPlaylist() { + let tracks = this.matrix.getSelectedTracks(); + + if (tracks.length > 0) { + this.waitingOnRequest.set(true); + let req = await fetcher(`playlists/${this.playlistID()}/track-set`, { + method: 'POST', + body: JSON.stringify({ + 'track-ids': tracks.map((t) => t.id) + }) + }); + + // If success, add tracks to above UI + if (req.ok) { + console.log(tracks); + this.addTracks.emit(tracks); + this.matrix.clearSelectedTracks(); + } + + this.waitingOnRequest.set(false); + } + } + + public async onInput(ev: any) { + // Search for tracks. We made a debounce which waits 500ms before sending. + // Makes it a little easier on the server. + + // If the search input is empty, reset the filter + if (ev.target.value.length == 0) { + this.tracks.set(this.allTracks); + } + + clearTimeout(this.timeout); + this.timeout = setTimeout(async () => { + // Don't search for empty string + if (ev.target.value.trim() == '') return; + this.requestTracks(ev.target.value.trim()); + }, 500); + } + + public async requestTracks(search: string) { + // Find the amount of track that we have that match the regex. + let offset = 0; + + if (search.length == 0) { + offset = this.tracks().length; + } else { + const regex = new RegExp('%' + search + "%"); + this.tracks().forEach(val => { + if (regex.test(val.title)) { + offset += 1; + } + }); + if (offset < 75) { + offset = 0; + } + } + + // Search for tracks + this.loading.set(true); + let req = await fetcher('tracks/search?' + + new URLSearchParams({ + track: search, + track_offset: offset.toString() + }).toString()); + this.loading.set(false); + if (!req.ok && req.status == 200) return; + + let json = await req.json(); + for (let track of json) { + if (this.allTracks.some(v => v.id == track.id)) continue; + this.allTracks.push(new Track(track)); + } + this.tracks.set(this.getMatchingTracks(search)); + } + + public getMatchingTracks(search: string): Track[] { + let regex = new RegExp(search, 'i'); + return this.allTracks.filter((v) => regex.test(v.track.title)); + } +} diff --git a/osse-web/src/app/playlist/playlist-view/playlist-view.component.html b/osse-web/src/app/playlist/playlist-view/playlist-view.component.html new file mode 100644 index 0000000..a62f171 --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/playlist-view.component.html @@ -0,0 +1,75 @@ +
+ + +
+ + + +
+ + + + @if (playlist()) { +
+
+
+ + + +
+ +

Click on a track to add it to the queue. Right click (or hold) a track to enable additional options. +

+ +
+ +
+ +
+ +
+
+
+ +
+
+ + +
+ +

The playlist name must be at + least 1 character.

+ + +
+
+ Delete +
+

Deleting a playlist is a permanant action that cannot be undone.

+ +
+ +
+ } +
diff --git a/osse-web/src/app/playlist/playlist-view/playlist-view.component.ts b/osse-web/src/app/playlist/playlist-view/playlist-view.component.ts new file mode 100644 index 0000000..59ec700 --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/playlist-view.component.ts @@ -0,0 +1,156 @@ +import { Component, computed, Input, numberAttribute, signal, ViewChild, WritableSignal } from '@angular/core'; +import { Playlist } from '../../shared/services/playlist/Playlist'; +import { HeaderComponent } from '../../shared/ui/header/header.component'; +import { Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { EditPlaylist } from './editPlaylistModel'; +import { FormsModule } from '@angular/forms'; +import { IconComponent } from '../../shared/ui/icon/icon.component'; +import { mdiClose, mdiPencil, mdiPlaylistPlay, mdiTrashCan } from '@mdi/js'; +import { PlaylistService } from '../../shared/services/playlist/playlist.service'; +import { TrackService } from '../../shared/services/track/track.service'; +import { ToastService } from '../../toast-container/toast.service'; +import { Track } from '../../shared/services/track/track'; +import { fetcher } from '../../shared/util/fetcher'; +import { TrackMatrixComponent } from '../../shared/ui/track-matrix/track-matrix.component'; +import { TrackMatrixMode } from '../../shared/ui/track-matrix/track-matrix-mode.enum'; +import { PlaylistAddTracksComponent } from './playlist-add-tracks/playlist-add-tracks.component'; + +@Component({ + selector: 'app-playlist-view', + imports: [HeaderComponent, IconComponent, CommonModule, FormsModule, TrackMatrixComponent, PlaylistAddTracksComponent], + templateUrl: './playlist-view.component.html', + styles: `` +}) +export class PlaylistViewComponent { + @ViewChild(TrackMatrixComponent) tracks!: TrackMatrixComponent; + @Input({ transform: numberAttribute }) + set id(id: number) { + this.getPlaylist(id); + } + + pencil = mdiPencil; + trash = mdiTrashCan; + play = mdiPlaylistPlay; + close = mdiClose; + + public playlist = signal(null); + public showTrackSelectionMenu: WritableSignal = signal(false); + public ready: WritableSignal = signal(false); + public waitingOnRequest: WritableSignal = signal(false); + public activeTab = signal('view'); + public showViewTab = computed(() => this.activeTab() == 'view'); + public showAddTracksTab = computed(() => this.activeTab() == 'addTracks'); + public showModifyTab = computed(() => this.activeTab() == 'modify'); + public model = new EditPlaylist(''); + + constructor( + private router: Router, + private playlistService: PlaylistService, + private trackService: TrackService, + private notificationService: ToastService + ) { } + + public delete() { + if (confirm(`Are you sure you want to delete ${this.playlist()!.name}?`)) { + fetcher('playlists/' + this.playlist()?.id, { + method: 'DELETE', + }).then((_r) => { + this.router.navigate(['/playlists']); + }) + } + } + + public async edit() { + let req = await fetcher('playlists/' + this.playlist()?.id, { + method: 'PATCH', + body: JSON.stringify({ + name: this.model.name + }), + headers: [ + ['Content-Type', 'application/json'] + ] + }); + + if (req.ok) { + this.getPlaylist(this.playlist()?.id as number); + this.notificationService.info('Playlist renamed successfully.'); + this.activeTab.set('view'); + } + } + + public addTrack(track: Track) { + this.trackService.addTrack(track); + this.notificationService.info('Added ' + track.title); + } + + public addTracksToQueue() { + let tracks = this.playlist()!.tracks; + for (let track of tracks) { + this.trackService.addTrack(track); + } + + this.notificationService.info('Added ' + tracks.length + ' tracks'); + } + + private async getPlaylist(id: number) { + this.playlist.set(await this.playlistService.getPlaylist(id)); + this.model.name = this.playlist()?.name ?? ''; + this.ready.set(true); + } + + public onTrackMatrixModeChange(mode: TrackMatrixMode) { + this.showTrackSelectionMenu.set(mode == TrackMatrixMode.Select); + } + + public closePlaylistTrackSelector() { + this.tracks.setMode(TrackMatrixMode.View); + this.tracks.clearSelectedTracks(); + } + + public async removeTracksFromPlaylist() { + let tracks = this.tracks.getSelectedTracks(); + if (tracks.length == 0) { + return; + } + + this.waitingOnRequest.set(true); + let req = await fetcher(`playlists/${this.playlist()?.id}/track-set`, { + method: 'DELETE', + body: JSON.stringify({ + 'track-ids': tracks.map((t) => t.id) + }) + }); + + if (req.ok) { + this.playlist.update((p) => { + p!.tracks = p!.tracks.filter((t) => !tracks.some((t2) => t2.id == t.id)) + return p; + }); + + this.notificationService.info('Removed ' + tracks.length + ' tracks from ' + this.playlist()!.name); + this.tracks.clearSelectedTracks(); + this.tracks.setMode(TrackMatrixMode.View); + } + + this.waitingOnRequest.set(false); + } + + addTracksToPlaylist(tracks: Track[]) { + this.playlist.update((p) => { + p!.tracks.push(...tracks); + return p; + }) + this.notificationService.info('Added ' + tracks.length + ' tracks to ' + this.playlist()!.name); + this.activeTab.set('view'); + this.tracks.clearSelectedTracks(); + this.tracks.setMode(TrackMatrixMode.View); + } + + playAll() { + let tracks = this.tracks.tracks(); + tracks.forEach((t) => this.trackService.addTrack(t)); + this.notificationService.info('Added ' + tracks.length + ' tracks.'); + } +} + diff --git a/osse-web/src/app/playlist/playlist.component.html b/osse-web/src/app/playlist/playlist.component.html new file mode 100644 index 0000000..48b83f0 --- /dev/null +++ b/osse-web/src/app/playlist/playlist.component.html @@ -0,0 +1,21 @@ +
+ + +
+
+ +
+ @for (playlist of playlists(); track $index) { +
+

+ {{playlist.name}} + {{playlist.count}} tracks +

+
+ } @empty { +

No Playlists were found.

+ } +
+
diff --git a/osse-web/src/app/playlist/playlist.component.ts b/osse-web/src/app/playlist/playlist.component.ts new file mode 100644 index 0000000..ab7a80d --- /dev/null +++ b/osse-web/src/app/playlist/playlist.component.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component, OnInit, WritableSignal, signal } from '@angular/core'; +import { Playlist } from '../shared/services/playlist/Playlist'; +import { OssePlaylist } from '../shared/services/playlist/osse-playlist'; +import { Router, RouterLink } from '@angular/router'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { IconComponent } from '../shared/ui/icon/icon.component'; +import { mdiPlus, mdiRefresh } from '@mdi/js'; +import { fetcher } from '../shared/util/fetcher'; + +@Component({ + selector: 'app-playlist', + imports: [RouterLink, HeaderComponent, IconComponent], + templateUrl: './playlist.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: `` +}) +export class PlaylistComponent implements OnInit { + public playlists: WritableSignal = signal([]); + plus = mdiPlus; + refresh = mdiRefresh; + + constructor( + private router: Router + ) { } + + ngOnInit(): void { + this.refreshPlaylistList(); + } + + public async createPlaylist() { + let request = await fetcher('playlists', { + method: 'POST', + body: JSON.stringify({ + 'name': "Default" + }), + headers: [ + ['Content-Type', 'application/json'] + ] + }); + + if (request.ok) { + let res = await request.json(); + this.router.navigateByUrl("playlists/view/" + res.id); + } + } + + public async refreshPlaylistList() { + let request = await fetcher('playlists'); + let result = await request.json(); + this.playlists.set(result.map((p: OssePlaylist) => new Playlist(p))); + } +} diff --git a/osse-web/src/app/registration/registration.component.html b/osse-web/src/app/registration/registration.component.html new file mode 100644 index 0000000..efe3b64 --- /dev/null +++ b/osse-web/src/app/registration/registration.component.html @@ -0,0 +1,69 @@ +
+ + +
+

Welcome to the Osse music server!

+ + @if (showConnectionInputs()) { +
+

To begin, enter the server URL. This should include the host and port. The default URL has + been filled in for you.

+
+ + +
+ +
+ + +
+ +
+ + + @if (waitingForResponse()) { + + } +
+
+ } + + @if (serverFound()) { +

Please create your account.

+ +
+
+ + +
+ +
+ + +
+ +
+ + @if (waitingForResponse()) { + + } +
+
+ } + + @if (!serverFound() && !showConnectionInputs()) { +

Connecting to API...

+ } + + @if (serverFound() || showConnectionInputs()) { +

Already have an account? Login

+ } +
+
diff --git a/osse-web/src/app/registration/registration.component.ts b/osse-web/src/app/registration/registration.component.ts new file mode 100644 index 0000000..c54b94d --- /dev/null +++ b/osse-web/src/app/registration/registration.component.ts @@ -0,0 +1,99 @@ +import { Component, OnInit, signal, WritableSignal } from '@angular/core'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { ToastService } from '../toast-container/toast.service'; +import { ConfigService } from '../shared/services/config/config.service'; +import { Router } from '@angular/router'; +import { AuthService } from '../shared/services/auth/auth.service'; +import { FormsModule } from '@angular/forms'; +import { fetcher } from '../shared/util/fetcher'; + +@Component({ + selector: 'app-registration', + imports: [HeaderComponent, FormsModule], + templateUrl: './registration.component.html', + styles: `` +}) +export class RegistrationComponent implements OnInit { + public username: string = ''; + public password: string = ''; + public serverFound: WritableSignal = signal(false); + public url: WritableSignal = signal(window.location.hostname + ':8000'); + public protocol: WritableSignal = signal('http://'); + public showConnectionInputs: WritableSignal = signal(false); + public waitingForResponse = signal(false); + + constructor( + private notificationService: ToastService, + private configService: ConfigService, + private router: Router, + private authService: AuthService + ) { } + + public async saveURL() { + if (!this.url().endsWith("/")) { + this.url.update(u => u + "/"); + } + + // Check if the server URL is right. + try { + this.waitingForResponse.set(true); + await fetch(this.protocol().concat(this.url()) + 'api/ping'); + // Save the URL + this.configService.save("apiURL", this.protocol().concat(this.url())); + this.notificationService.info("URL saved as " + this.configService.get("apiURL")); + this.serverFound.set(true); + } catch (e) { + this.notificationService.error('Failed to reach server. Confirm that the URL is correct and that the server is running.'); + } finally { + this.waitingForResponse.set(false); + } + } + + public async register() { + if (this.username.length == 0 || this.password.length == 0) { + this.notificationService.error('You must enter a username and password.'); + return; + } + + this.waitingForResponse.set(true); + + let res = await fetcher('register', { + method: 'POST', + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + rootURL: this.configService.get('apiURL') + }); + + this.waitingForResponse.set(false); + + if (res.status == 403) { + this.notificationService.error('Account creation error. Check that registration is enabled in your server settings. '); + return; + } else if (res.status == 422) { + this.notificationService.error('Account creation error. Check that the username is not in use and that the username and password are at least 1 character. '); + return; + } + + if (res.ok) { + this.notificationService.info('Account created successfully! Welcome to Osse, ' + this.username + '.'); + await this.authService.attemptLogin(); + this.router.navigateByUrl('/home'); + } else { + this.notificationService.error('Login error.'); + } + } + + async ngOnInit() { + // Try to login with the default URL. + try { + await fetch(this.configService.get('apiURL') + 'api/ping'); + this.serverFound.set(true); + } catch (e) { + // This should only happen in dev. If it fails, show the server URL inputs. + this.notificationService.error('Failed to autodetect server URL. Please enter it.'); + this.showConnectionInputs.set(true); + } + } +} diff --git a/osse-web/src/app/settings/scan-progress.interface.ts b/osse-web/src/app/settings/scan-progress.interface.ts new file mode 100644 index 0000000..c40c2a0 --- /dev/null +++ b/osse-web/src/app/settings/scan-progress.interface.ts @@ -0,0 +1,7 @@ +export interface ScanProgress { + active: boolean; + // If active is true, the below fields are present. + total_directories?: number; + finished_count?: number + nextDir?: string | null; +} diff --git a/osse-web/src/app/settings/settings-logs/settings-logs.component.html b/osse-web/src/app/settings/settings-logs/settings-logs.component.html new file mode 100644 index 0000000..dedaa57 --- /dev/null +++ b/osse-web/src/app/settings/settings-logs/settings-logs.component.html @@ -0,0 +1,14 @@ + + +

Below are the current application logs. The last 500 lines are shown.

+ +@if (!showLogs()) { +
+ +
+} @else { + +
{{logs()}}
+} diff --git a/osse-web/src/app/settings/settings-logs/settings-logs.component.ts b/osse-web/src/app/settings/settings-logs/settings-logs.component.ts new file mode 100644 index 0000000..b70ca18 --- /dev/null +++ b/osse-web/src/app/settings/settings-logs/settings-logs.component.ts @@ -0,0 +1,46 @@ +import { Component, OnInit, signal, WritableSignal } from '@angular/core'; +import { HeaderComponent } from '../../shared/ui/header/header.component'; +import { fetcher } from '../../shared/util/fetcher'; +import { ToastService } from '../../toast-container/toast.service'; + +@Component({ + selector: 'app-settings-logs', + imports: [HeaderComponent], + templateUrl: './settings-logs.component.html', + styles: `` +}) +export class SettingsLogsComponent implements OnInit { + public showLogs: WritableSignal = signal(false); + public logs: WritableSignal = signal(''); + + constructor(private notificationService: ToastService) { } + + viewLogs() { + this.showLogs.set(true); + } + + private async requestLogs() { + let res = await fetcher('config/logs'); + + if (res.ok) { + this.logs.set(await res.text()); + return true; + } + + return false; + } + + public async refreshLogs() { + let success = await this.requestLogs(); + if (success) { + this.notificationService.info('Logs updated.'); + } else { + this.notificationService.error('Failed to access logs.'); + } + + } + + ngOnInit(): void { + this.requestLogs().then((r) => !r ? this.notificationService.error('Failed to access logs.') : null); + } +} diff --git a/osse-web/src/app/settings/settings-preferences/osse-config.ts b/osse-web/src/app/settings/settings-preferences/osse-config.ts new file mode 100644 index 0000000..66e26ef --- /dev/null +++ b/osse-web/src/app/settings/settings-preferences/osse-config.ts @@ -0,0 +1,4 @@ +export type OsseConfigResponse = { + queueEnabled: boolean, + directories: string[] +} diff --git a/osse-web/src/app/settings/settings-preferences/settings-preferences.component.html b/osse-web/src/app/settings/settings-preferences/settings-preferences.component.html new file mode 100644 index 0000000..d5f8215 --- /dev/null +++ b/osse-web/src/app/settings/settings-preferences/settings-preferences.component.html @@ -0,0 +1,98 @@ + + +
+
+
+

Background Image

+

+ When a track or album is playing, Osse will dispay the cover art in the background. + This may cause contrast issues with certain covers. +

+
+ +
+ + +
+
+ +
+
+
+

Visualizer

+

+ When a track is playing, Osse will dispay a music visualizer. This is generated in real-time. + This may cause performance issues with certain devices.

+
+ +
+
+ + +
+ +
+ + + + +
+
+
+ +
+
+
+

+ Save Queue +

+

Osse can save your queue to the server. This allows you to pick up where you left off when Osse is closed and reopened. + This is an account level setting.

+
+ +
+ + +
+
+ +
+ + + +
diff --git a/osse-web/src/app/settings/settings-preferences/settings-preferences.component.ts b/osse-web/src/app/settings/settings-preferences/settings-preferences.component.ts new file mode 100644 index 0000000..fcf3906 --- /dev/null +++ b/osse-web/src/app/settings/settings-preferences/settings-preferences.component.ts @@ -0,0 +1,97 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ConfigService } from '../../shared/services/config/config.service'; +import { HeaderComponent } from "../../shared/ui/header/header.component"; +import { IconComponent } from "../../shared/ui/icon/icon.component"; +import { mdiChartBar, mdiContentSave, mdiImage, mdiRestore } from '@mdi/js'; +import { fetcher } from '../../shared/util/fetcher'; +import { ToastService } from '../../toast-container/toast.service'; +import { OsseConfigResponse } from './osse-config'; + +@Component({ + selector: 'app-settings-preferences', + imports: [ReactiveFormsModule, HeaderComponent, IconComponent], + templateUrl: './settings-preferences.component.html', + styles: `` +}) +export class SettingsPreferencesComponent implements OnInit { + private formBuilder = inject(FormBuilder); + preferencesForm = this.formBuilder.group({ + showBackgroundArt: [false], + showVisualizer: [false], + visualizerSamples: [1, [Validators.min(1), Validators.max(10)]], + enableQueue: [false], + }); + + public waitingForResponse = signal(false); + + public visualizerIcon = mdiChartBar; + public imageIcon = mdiImage; + public queueIcon = mdiRestore; + public saveIcon = mdiContentSave; + + constructor(private configService: ConfigService, private notificationService: ToastService) { } + + async onSubmit() { + // Only save valid data + if (!this.preferencesForm.valid) { + return; + } + + this.waitingForResponse.set(true); + + // Save local data first + this.configService.saveMany({ + showCoverBackgrounds: this.preferencesForm.value.showBackgroundArt as boolean, + showVisualizer: this.preferencesForm.value.showVisualizer as boolean, + visualizerSamples: this.preferencesForm.value.visualizerSamples as number, + }); + + // Now, save account data + let res = await fetcher('config', { + method: 'POST', + body: JSON.stringify({ + enableQueue: this.preferencesForm.value.enableQueue ?? false + }) + }); + + if (res.ok) { + this.notificationService.info('Preferences Saved!'); + } else { + this.notificationService.error('Failed to save account preferences. Try saving again.'); + } + + this.waitingForResponse.set(false); + } + + private async requestSettings(): Promise { + let res = await fetcher('config'); + + if (res.ok) { + let response = await res.json(); + return response as OsseConfigResponse; + } else { + this.notificationService.error('Failed to reach server. Check that the URL is correct and that the server is running.'); + throw 'Config Error'; + } + } + + async ngOnInit(): Promise { + this.waitingForResponse.set(true); + + let conf; + try { + conf = await this.requestSettings(); + } catch (error) { } + + // Store the values and set them in the form + this.preferencesForm.setValue({ + showBackgroundArt: this.configService.get('showCoverBackgrounds'), + showVisualizer: this.configService.get('showVisualizer'), + visualizerSamples: this.configService.get('visualizerSamples'), + enableQueue: conf?.queueEnabled ?? false, + }); + + this.waitingForResponse.set(false); + } +} diff --git a/osse-web/src/app/settings/settings-scan-history/history.ts b/osse-web/src/app/settings/settings-scan-history/history.ts new file mode 100644 index 0000000..159e93e --- /dev/null +++ b/osse-web/src/app/settings/settings-scan-history/history.ts @@ -0,0 +1,25 @@ +export interface ScanJob { + id: number; + finished_at: string; + started_at: string; + status: string; + total_dirs: number; + directories: ScanDirectory[]; +} + +export interface ScanDirectory { + id: number; + files_scanned: number; + files_skipped: number; + status: string; + path: string; + errors: ScanError[]; + show: boolean; // This doesn't exist, but we add it client side. +} + +export interface ScanError { + id: number; + scan_directory_id: number; + error: string; + created_at: string; +} diff --git a/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.html b/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.html new file mode 100644 index 0000000..c9bc27f --- /dev/null +++ b/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.html @@ -0,0 +1,33 @@ + + +@if (isLoading()) { +

Fetching history. Please wait...

+} + +
+

Scans are listed with the most recent first. Scans are cleared on a weekly basis.

+ @for (job of jobs(); track $index) { +
+

Scan {{job.id}}: {{job.directories.length}} directories scanned.

+

{{job.started_at}} - {{job.finished_at}}

+ @for (dir of job.directories; track $index) { +
+

{{dir.path}} - {{dir.files_scanned}} files scanned | {{dir.files_skipped}} files skipped. Status: {{dir.status}}

+ @if (dir.errors.length > 0) { + @if (!dir.show) { + + } + @if (dir.show) { + + @for (err of dir.errors; track $index) { +
{{err.error}}
+ } + } + } +
+ } +
+ } @empty { +

No scans were found...

+ } +
diff --git a/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.ts b/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.ts new file mode 100644 index 0000000..88832cb --- /dev/null +++ b/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.ts @@ -0,0 +1,49 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { HeaderComponent } from "../../shared/ui/header/header.component"; +import { fetcher } from '../../shared/util/fetcher'; +import { ToastService } from '../../toast-container/toast.service'; +import { ScanJob } from './history'; + +@Component({ + selector: 'app-settings-scan-history', + imports: [HeaderComponent], + templateUrl: './settings-scan-history.component.html', + styles: `` +}) +export class SettingsScanHistoryComponent implements OnInit { + public isLoading = signal(true); + public jobs = signal([]) + + constructor(private notificationService: ToastService) { } + + public async requestHistory() { + let req = await fetcher('scan/history'); + if (req.ok) { + // Set the directories. + let resp: ScanJob[] = await req.json(); + this.jobs.set(resp.map((j) => { + let start = new Date(j.started_at); + j.started_at = start.toLocaleString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true }); + + if (!j.finished_at) { + j.finished_at = 'now...' + } else { + let end = new Date(j.finished_at); + j.finished_at = end.toLocaleString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true }); + } + + return j; + })); + + // If a scan is active, set that status. + } else { + this.notificationService.error('Something went wrong when requesting the scan history. Try reloading the page.'); + } + + this.isLoading.set(false); + } + + ngOnInit(): void { + this.requestHistory(); + } +} diff --git a/osse-web/src/app/settings/settings-scan/settings-scan.component.html b/osse-web/src/app/settings/settings-scan/settings-scan.component.html new file mode 100644 index 0000000..c427e39 --- /dev/null +++ b/osse-web/src/app/settings/settings-scan/settings-scan.component.html @@ -0,0 +1,129 @@ + + +@if (fetchingScanStatus()) { +

Fetching scan status. Please wait...

+} +@if (!fetchingScanStatus() && !scanInProgress()) { +

+ Scan Settings +

+ +@if (scanCompleted()) { + +} + +

You can start a track scan from the web GUI. This will scan the below directories as listed in + your .env file. Each directory will be scanned recursively. This means each directory should not be the parent of a directory listed here.

+

For example, if I have 20 folders in the "Music" directory, I would simply use the "Music" directory, not 20 separate entries.

+ +
+
+ + + Scan Options + +
+ +
    + @for (dir of rootDirectories(); track $index) { +
  • {{dir}}
  • + } @empty { +
  • No directories found...
  • + } +
+
+ +

+ *A fresh scan will empty the entire database before scanning. You should only use this option if a metadata update has occured (such as adding new fields to tracks), or if you are having issues with the scanner. +

+} + +@if (!fetchingScanStatus() && scanInProgress()) { +

+ Active Scan +

+ + + +
+
+ + +
+

Scanned {{amountOfDirectoriesComplete()}} of {{totalAmountOfDirectoriesToScan()}} directories.

+ + + @if (waitingForCancelConfirmation()) { +

Scan cancel request has been sent. It will be cancelled before the next directory is scanned.

+ } +
+ +

The directories will be scanned in the below order. Depending the the speed of the storage drive and + network connection, this may take some time.

+ +
    + @for (dir of scanProgress(); track $index) { +
  • + @if (!$first) { +
    + } +
    {{dir.path}}
    +
    + @if (dirIsScanning(dir.status)) { + + + + } @else { + + + + } +
    +

    Scanned {{dir.filesScanned}} files and skipped {{dir.filesSkipped}}.

    + @if (!$last) { +
    + } +
  • + } +
+} + +@if (scanLogs()) { +

+ Scan Log +

+ +

+ Below is a log of all directory events in the current scan. +

+ +
{{scanLogs()}}
+} + +@if (scanErrorMessages()) { +

+ Scan Errors +

+ +

+ Below is a list of all errors that have occured during the current scan. + In most cases, these simply affect a single file and the rest of the directory will process normally. +

+ +
{{scanErrorMessages()}}
+} diff --git a/osse-web/src/app/settings/settings-scan/settings-scan.component.ts b/osse-web/src/app/settings/settings-scan/settings-scan.component.ts new file mode 100644 index 0000000..42f5c38 --- /dev/null +++ b/osse-web/src/app/settings/settings-scan/settings-scan.component.ts @@ -0,0 +1,213 @@ +import { Component, computed, OnDestroy, OnInit, signal } from '@angular/core'; +import { HeaderComponent } from '../../shared/ui/header/header.component'; +import { ScanChannels, ScanDirectory, ScanDirectoryStatus } from '../../shared/services/echo/channels/scan'; +import { merge, Subscription } from 'rxjs'; +import { EchoService } from '../../shared/services/echo/echo.service'; +import { ToastService } from '../../toast-container/toast.service'; +import { fetcher } from '../../shared/util/fetcher'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-settings-scan', + imports: [HeaderComponent, CommonModule, FormsModule], + templateUrl: './settings-scan.component.html', + styles: `` +}) +export class SettingsScanComponent implements OnInit, OnDestroy { + public fetchingScanStatus = signal(true); + + public rootDirectories = signal([]); + public waitingForScanConfirmation = signal(false); + + public scanInProgress = signal(false); + public scanProgress = signal([]); + + public waitingForCancelConfirmation = signal(false); + + public scanErrorMessages = signal(""); + public scanCompleted = signal(false); + public scanLogs = signal(''); + + public freshScan = false; + /** + * Counts how many directories are in a complete state (scanned/errored) + */ + public amountOfDirectoriesComplete = computed(() => { + return this.scanProgress().filter((d) => d.status == ScanDirectoryStatus.Scanned || d.status == ScanDirectoryStatus.Errored).length; + }); + /** + * Percent of how many directories are in a complete state (scanned/errored) + */ + public percentOfScanComplete = computed(() => { + let dirs = this.scanProgress(); + // Ensure there's at least 1 directory to avoid division by zero + if (dirs.length === 0) return 0; + + const completeDirs = dirs.filter((d) => d.status == ScanDirectoryStatus.Scanned || d.status == ScanDirectoryStatus.Errored); + + return Math.floor((completeDirs.length / dirs.length) * 100); + }); + /** + * Counts the total amount of directories to scan. + */ + public totalAmountOfDirectoriesToScan = computed(() => this.scanProgress().length); + + constructor(private echoService: EchoService, private notificationService: ToastService) { } + + /** + * This subscription links the child subscriptions and allows unsubscribing from them all at once on deInit. + */ + private subscription!: Subscription; + + public async requestScan() { + this.waitingForScanConfirmation.set(true); + + let scanURL = this.freshScan ? 'scan/fresh' : 'scan'; + + let req = await fetcher(scanURL, { + method: 'POST' + }); + + if (!req.ok) { + this.notificationService.error('Failed to start scan. Please check that all directories exist, are readable, free of typos, and mounted (if a network/removeable disk)'); + this.waitingForScanConfirmation.set(false); + } + } + + public async cancelScan() { + this.waitingForCancelConfirmation.set(true); + + fetcher('scan/cancel', { + method: 'POST' + }); + } + + public dirIsScanning(status: ScanDirectoryStatus) { + return status == ScanDirectoryStatus.Scanning; + } + + public dirScanned(status: ScanDirectoryStatus) { + return status == ScanDirectoryStatus.Scanned || status == ScanDirectoryStatus.Errored; + } + + public dirScannedOrScanning(status: ScanDirectoryStatus) { + return this.dirScanned(status) || status == ScanDirectoryStatus.Scanning; + } + + public getScanColor(status: ScanDirectoryStatus) { + switch (status) { + case ScanDirectoryStatus.Scanning: + return "aqua"; + case ScanDirectoryStatus.Scanned: + return "green"; + case ScanDirectoryStatus.Errored: + return "red"; + case ScanDirectoryStatus.Pending: + return "white"; + } + } + + + private async requestScanProgress() { + let req = await fetcher('scan'); + if (req.ok) { + // Set the directories. + let resp = await req.json(); + this.rootDirectories.set(resp.rootDirectories); + + if (resp.active) { + this.scanInProgress.set(true); + this.scanProgress.set(resp.directories); + + let messages = []; + // Generate the scan log. + for (const dir of resp.directories) { + if (dir.status != 'scanned') { + messages.push(`${dir.path} has a status of ${dir.status}`); + } else { + messages.push(`Scanned ${dir.files_scanned} files and skipped ${dir.files_skipped} in ${dir.path} - Status ${dir.status}`); + } + } + + this.scanLogs.set(messages.join('\n')); + } + + // If a scan is active, set that status. + } else { + this.notificationService.error('Something went wrong when requesting the scan status. Try reloading the page.') + } + + this.fetchingScanStatus.set(false); + } + + async ngOnInit(): Promise { + // Request scan progress, but don't wait for it. + this.requestScanProgress(); + + const scanStarted$ = this.echoService.subscribeToEvent(ScanChannels.ScanStarted, (data) => { + this.notificationService.info(`Started scanning ${data.directories.length} directories.`); + this.waitingForScanConfirmation.set(false); + this.scanProgress.set(data.directories); + this.scanInProgress.set(true); + this.scanCompleted.set(false); + this.scanLogs.set('Scan Started...'); + }); + const scanProgressed$ = this.echoService.subscribeToEvent(ScanChannels.ScanProgressed, (data) => { + this.scanProgress.update((scanProgress) => { + // On progress update, set the new progress. There should always be a match + let indexOfProgressDir = -1; + return scanProgress.map((dir, index) => { + if (dir.id == data.directoryID) { + dir.status = data.status; + dir.filesScanned = data.filesScanned; + dir.filesSkipped = data.filesSkipped; + indexOfProgressDir = index; + return dir; + } + + // If its not the match, check if its the item after the match. + // If so, set the scanning status. + if (indexOfProgressDir != -1 && indexOfProgressDir + 1 == index) { + dir.status = ScanDirectoryStatus.Scanning; + } + + return dir; + }); + }) + + this.scanLogs.update((l) => l + `\nScanned dir ${data.directoryName} with ${data.filesScanned} files scanned and ${data.filesSkipped} files skipped.`); + }); + const scanCompleted$ = this.echoService.subscribeToEvent(ScanChannels.ScanCompleted, (data) => { + this.notificationService.info(`Finished scanning ${data.directoryCount} directories.`) + this.scanInProgress.set(false); + this.scanProgress.set([]); + this.scanCompleted.set(true); + this.scanLogs.update((l) => l + '\nScan Complete...'); + }); + + const scanError$ = this.echoService.subscribeToEvent(ScanChannels.ScanError, (data) => { + this.notificationService.error('A scan error has occured. Continuing...'); + this.scanErrorMessages.update((e) => e + data.message + '\n'); + }); + const scanFailed$ = this.echoService.subscribeToEvent(ScanChannels.ScanFailed, (data) => { + this.notificationService.error('Scan Failed! The scan will be cancelled at the current directory.'); + this.scanErrorMessages.update((e) => e + data.message + '\n'); + this.scanInProgress.set(false); + this.scanLogs.update((l) => l + '\nScan Failed...'); + }); + const scanCancelled$ = this.echoService.subscribeToEvent(ScanChannels.ScanCancelled, (data) => { + this.notificationService.info(`Scan has been cancelled. ${data.directoriesScannedBeforeCancellation} directories were scanned in.`); + this.scanInProgress.set(false); + this.waitingForCancelConfirmation.set(false); + this.scanProgress.set([]); + this.scanLogs.update((l) => l + '\nScan Cancelled...'); + }); + + this.subscription = merge(scanStarted$, scanProgressed$, scanCompleted$, scanError$, scanFailed$, scanCancelled$).subscribe(); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/osse-web/src/app/settings/settings.component.html b/osse-web/src/app/settings/settings.component.html new file mode 100644 index 0000000..fb11ff5 --- /dev/null +++ b/osse-web/src/app/settings/settings.component.html @@ -0,0 +1,42 @@ + + +
+ +
+ + + + +
+ @if (activeTab() === 'scan') { +
+ +
+ } @else if (activeTab() === 'scan-history') { + + } @else if (activeTab() === 'preferences') { +
+ +
+ } @else if (activeTab() === 'logs') { +
+ +
+ } +
diff --git a/osse-web/src/app/settings/settings.component.ts b/osse-web/src/app/settings/settings.component.ts new file mode 100644 index 0000000..5939ea7 --- /dev/null +++ b/osse-web/src/app/settings/settings.component.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, WritableSignal, signal } from '@angular/core'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { ToastService } from '../toast-container/toast.service'; +import { fetcher } from '../shared/util/fetcher'; +import { CommonModule } from '@angular/common'; +import { SettingsLogsComponent } from './settings-logs/settings-logs.component'; +import { SettingsScanComponent } from "./settings-scan/settings-scan.component"; +import { SettingsScanHistoryComponent } from './settings-scan-history/settings-scan-history.component'; +import { SettingsPreferencesComponent } from "./settings-preferences/settings-preferences.component"; + +@Component({ + selector: 'app-settings', + imports: [HeaderComponent, CommonModule, SettingsLogsComponent, SettingsScanComponent, SettingsScanHistoryComponent, SettingsPreferencesComponent], + templateUrl: './settings.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SettingsComponent implements OnInit { + @ViewChild('samples') sampleElement!: ElementRef; + public activeTab = signal('scan'); + + public directories: WritableSignal = signal([]); + + constructor( + private notificationService: ToastService, + ) { } + + public async requestSettings() { + let res = await fetcher('config'); + + if (res.ok) { + let response = await res.json(); + this.directories.set(response.directories); + } else { + this.notificationService.error('Failed to reach server. Check that the URL is correct and that the server is running.'); + } + } + + async ngOnInit(): Promise { + await this.requestSettings(); + } +} diff --git a/osse-web/src/app/shared/player/buffer-update.interface.ts b/osse-web/src/app/shared/player/buffer-update.interface.ts new file mode 100644 index 0000000..f9d3101 --- /dev/null +++ b/osse-web/src/app/shared/player/buffer-update.interface.ts @@ -0,0 +1,9 @@ +export interface BufferUpdate { + /** + * Duration from the audio player. + * This is usually inaccurate at the early parts of the song when it is not entirely downloaded. + * It is usually accurate at the end when the entire song is downloaded. + */ + duration: number; + buffered: TimeRanges; +} diff --git a/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.html b/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.html new file mode 100644 index 0000000..5b0908f --- /dev/null +++ b/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.html @@ -0,0 +1,6 @@ +
+ +
diff --git a/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.ts b/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.ts new file mode 100644 index 0000000..994c5f7 --- /dev/null +++ b/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { TrackService } from '../../services/track/track.service'; +import { mdiDeleteSweep } from '@mdi/js'; +import { IconComponent } from '../../ui/icon/icon.component'; + +@Component({ + selector: 'app-clear-queue-controls', + imports: [IconComponent], + templateUrl: './clear-queue-controls.component.html', + styles: `` +}) +export class ClearQueueControlsComponent { + constructor(private trackService: TrackService) { } + + clear = mdiDeleteSweep; + + clearQueue() { + this.trackService.clearTracks(); + } +} diff --git a/osse-web/src/app/shared/player/duration/duration.component.html b/osse-web/src/app/shared/player/duration/duration.component.html new file mode 100644 index 0000000..417d6d3 --- /dev/null +++ b/osse-web/src/app/shared/player/duration/duration.component.html @@ -0,0 +1 @@ +

{{duration()}}

diff --git a/osse-web/src/app/shared/player/duration/duration.component.ts b/osse-web/src/app/shared/player/duration/duration.component.ts new file mode 100644 index 0000000..b6bdf6b --- /dev/null +++ b/osse-web/src/app/shared/player/duration/duration.component.ts @@ -0,0 +1,21 @@ +import { Component, computed } from '@angular/core'; +import { PlayerService } from '../player.service'; +import { getNicelyFormattedTime } from '../../util/time'; + +@Component({ + selector: 'app-duration', + imports: [], + templateUrl: './duration.component.html', + styles: `` +}) +export class DurationComponent { + public duration = computed(() => { + let currentTime = this.playerService.currentTime(); + let totalTime = this.playerService.duration(); + + return getNicelyFormattedTime(currentTime) + ' / ' + getNicelyFormattedTime(totalTime); + }) + + + constructor(private playerService: PlayerService) { } +} diff --git a/osse-web/src/app/shared/player/jump-controls/jump-controls.component.html b/osse-web/src/app/shared/player/jump-controls/jump-controls.component.html new file mode 100644 index 0000000..e3ea2e2 --- /dev/null +++ b/osse-web/src/app/shared/player/jump-controls/jump-controls.component.html @@ -0,0 +1,18 @@ +
+ + + + +
diff --git a/osse-web/src/app/shared/player/jump-controls/jump-controls.component.ts b/osse-web/src/app/shared/player/jump-controls/jump-controls.component.ts new file mode 100644 index 0000000..4134d92 --- /dev/null +++ b/osse-web/src/app/shared/player/jump-controls/jump-controls.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { PlayerService } from '../player.service'; +import { IconComponent } from '../../ui/icon/icon.component'; +import { mdiFastForward10, mdiFastForward30, mdiRewind10, mdiRewind30 } from '@mdi/js'; +import { TrackService } from '../../services/track/track.service'; + +@Component({ + selector: 'app-jump-controls', + imports: [IconComponent], + templateUrl: './jump-controls.component.html', + styles: `` +}) +export class JumpControlsComponent { + constructor(private trackService: TrackService, public playerService: PlayerService) { } + + back10 = mdiRewind10; + back30 = mdiRewind30; + forward10 = mdiFastForward10; + forward30 = mdiFastForward30; + + public jump(duration: number, jumpForward: boolean) { + if (this.trackService.activeTrack) { + this.playerService.jumpDuration(duration, jumpForward); + } + } +} diff --git a/osse-web/src/app/shared/player/media-session.service.ts b/osse-web/src/app/shared/player/media-session.service.ts new file mode 100644 index 0000000..7fd9f2d --- /dev/null +++ b/osse-web/src/app/shared/player/media-session.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; +import { PlayerService } from './player.service'; +import { TrackService } from '../services/track/track.service'; +import { PlaybackState } from './state-change'; + +@Injectable({ + providedIn: 'root' +}) +export class MediaSessionService { + constructor( + private playerService: PlayerService, + private trackService: TrackService + ) { + if ("mediaSession" in window.navigator) { + try { + this.listenForMediaEvents(); + } catch (error) { } + try { + this.listenForPlayerEvents(); + } catch (error) { } + } + } + + private listenForMediaEvents() { + navigator.mediaSession.setActionHandler("play", () => { + this.playerService.play(); + }); + navigator.mediaSession.setActionHandler("pause", () => { + this.playerService.pause(); + }); + navigator.mediaSession.setActionHandler("stop", () => { + this.playerService.pause(); + }); + navigator.mediaSession.setActionHandler("previoustrack", () => { + this.trackService.moveToLastTrack(); + }); + navigator.mediaSession.setActionHandler("nexttrack", () => { + this.trackService.moveToNextTrack(); + }); + navigator.mediaSession.setActionHandler('seekforward', (ev) => { + this.playerService.jumpDuration(ev.seekOffset || 10, true); + }); + navigator.mediaSession.setActionHandler('seekbackward', (ev) => { + this.playerService.jumpDuration(ev.seekOffset || 10, false); + }); + } + + private listenForPlayerEvents() { + this.playerService.trackUpdated.subscribe((t) => { + navigator.mediaSession.metadata = new MediaMetadata({ + title: t.title, + artist: t.artist?.name ?? 'Unknown Artist', + artwork: [ + { + src: t.cover + } + ] + }) + }); + + this.playerService.stateChanged.subscribe((s) => { + if (s == PlaybackState.Playing) { + navigator.mediaSession.playbackState = "playing"; + } else { + navigator.mediaSession.playbackState = "paused"; + } + }); + + this.playerService.playbackEnded.subscribe((_) => navigator.mediaSession.playbackState = "none"); + } +} diff --git a/osse-web/src/app/shared/player/pan-controls/pan-controls.component.html b/osse-web/src/app/shared/player/pan-controls/pan-controls.component.html new file mode 100644 index 0000000..a398947 --- /dev/null +++ b/osse-web/src/app/shared/player/pan-controls/pan-controls.component.html @@ -0,0 +1,12 @@ +
+ + +
+

L

+ +

R

+
+
diff --git a/osse-web/src/app/shared/player/pan-controls/pan-controls.component.ts b/osse-web/src/app/shared/player/pan-controls/pan-controls.component.ts new file mode 100644 index 0000000..73faf33 --- /dev/null +++ b/osse-web/src/app/shared/player/pan-controls/pan-controls.component.ts @@ -0,0 +1,57 @@ +import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; +import { WebAudioService } from '../web-audio.service'; +import { mdiRestart } from '@mdi/js'; +import { IconComponent } from '../../ui/icon/icon.component'; + +@Component({ + selector: 'app-pan-controls', + imports: [IconComponent], + templateUrl: './pan-controls.component.html', + styles: `` +}) +export class PanControlsComponent implements AfterViewInit { + @ViewChild('pan') panInput!: ElementRef; + reset = mdiRestart; + + constructor(private webAudioService: WebAudioService) { } + + setInitialPan(): void { + this.storeAndSetPan(Number(localStorage.getItem('pan') ?? 0)); + this.panInput.nativeElement.value = String(this.webAudioService.getPanValue()); + } + + onPanChange(event: any) { + this.storeAndSetPan(event.target.value); + } + + adjustPanByScroll(event: any) { + event.preventDefault(); + let currentPan = this.webAudioService.getPanValue(); + + let newPan; + if (event.deltaY > 0) { + newPan = Math.max(-1, currentPan - 0.05); + } else { + newPan = Math.min(1, currentPan + 0.05); + } + + this.panInput.nativeElement.value = String(newPan); + this.storeAndSetPan(newPan); + } + + onPanReset() { + this.panInput.nativeElement.value = "0"; + // The event isn't triggered. + this.storeAndSetPan(0); + } + + private storeAndSetPan(pan: number) { + this.webAudioService.setPan(pan); + localStorage.setItem('pan', pan.toString()); + } + + ngAfterViewInit(): void { + this.setInitialPan(); + } +} + diff --git a/osse-web/src/app/shared/player/player.component.css b/osse-web/src/app/shared/player/player.component.css new file mode 100644 index 0000000..628ba36 --- /dev/null +++ b/osse-web/src/app/shared/player/player.component.css @@ -0,0 +1,717 @@ +#track-title-container { + overflow: hidden; /* Hides the scrollbar */ +} + +#track-title-container p { + animation: scroll-text 10s linear infinite; +} + +@keyframes scroll-text { + 0% { + transform: translateX(100%); + } + 100% { + transform: translateX(-100%); + } +} + +/* Animate the point (cursor) movement */ +#point { + transition-duration: 1s; + transition-property: left; +} + +/* + A linear gradient with variables for each percent + CSS variables and properties support transitions in updated browsers +*/ +#rendered { + background: rgb(2,0,36); + background: linear-gradient( + 90deg, + var(--bar-c-0) 0%, var(--bar-c-1) 1%, var(--bar-c-2) 2%, + var(--bar-c-3) 3%, var(--bar-c-4) 4%, var(--bar-c-5) 5%, + var(--bar-c-6) 6%, var(--bar-c-7) 7%, var(--bar-c-8) 8%, + var(--bar-c-9) 9%, var(--bar-c-10) 10%, var(--bar-c-11) 11%, + var(--bar-c-12) 12%, var(--bar-c-13) 13%, var(--bar-c-14) 14%, + var(--bar-c-15) 15%, var(--bar-c-16) 16%, var(--bar-c-17) 17%, + var(--bar-c-18) 18%, var(--bar-c-19) 19%, var(--bar-c-20) 20%, + var(--bar-c-21) 21%, var(--bar-c-22) 22%, var(--bar-c-23) 23%, + var(--bar-c-24) 24%, var(--bar-c-25) 25%, var(--bar-c-26) 26%, + var(--bar-c-27) 27%, var(--bar-c-28) 28%, var(--bar-c-29) 29%, + var(--bar-c-30) 30%, var(--bar-c-31) 31%, var(--bar-c-32) 32%, + var(--bar-c-33) 33%, var(--bar-c-34) 34%, var(--bar-c-35) 35%, + var(--bar-c-36) 36%, var(--bar-c-37) 37%, var(--bar-c-38) 38%, + var(--bar-c-39) 39%, var(--bar-c-40) 40%, var(--bar-c-41) 41%, + var(--bar-c-42) 42%, var(--bar-c-43) 43%, var(--bar-c-44) 44%, + var(--bar-c-45) 45%, var(--bar-c-46) 46%, var(--bar-c-47) 47%, + var(--bar-c-48) 48%, var(--bar-c-49) 49%, var(--bar-c-50) 50%, + var(--bar-c-51) 51%, var(--bar-c-52) 52%, var(--bar-c-53) 53%, + var(--bar-c-54) 54%, var(--bar-c-55) 55%, var(--bar-c-56) 56%, + var(--bar-c-57) 57%, var(--bar-c-58) 58%, var(--bar-c-59) 59%, + var(--bar-c-60) 60%, var(--bar-c-61) 61%, var(--bar-c-62) 62%, + var(--bar-c-63) 63%, var(--bar-c-64) 64%, var(--bar-c-65) 65%, + var(--bar-c-66) 66%, var(--bar-c-67) 67%, var(--bar-c-68) 68%, + var(--bar-c-69) 69%, var(--bar-c-70) 70%, var(--bar-c-71) 71%, + var(--bar-c-72) 72%, var(--bar-c-73) 73%, var(--bar-c-74) 74%, + var(--bar-c-75) 75%, var(--bar-c-76) 76%, var(--bar-c-77) 77%, + var(--bar-c-78) 78%, var(--bar-c-79) 79%, var(--bar-c-80) 80%, + var(--bar-c-81) 81%, var(--bar-c-82) 82%, var(--bar-c-83) 83%, + var(--bar-c-84) 84%, var(--bar-c-85) 85%, var(--bar-c-86) 86%, + var(--bar-c-87) 87%, var(--bar-c-88) 88%, var(--bar-c-89) 89%, + var(--bar-c-90) 90%, var(--bar-c-91) 91%, var(--bar-c-92) 92%, + var(--bar-c-93) 93%, var(--bar-c-94) 94%, var(--bar-c-95) 95%, + var(--bar-c-96) 96%, var(--bar-c-97) 97%, var(--bar-c-98) 98%, + var(--bar-c-99) 99% + ); + + transition: 500ms ease-in; + transition-property: + --bar-c-0, --bar-c-1, --bar-c-2, + --bar-c-3, --bar-c-4, --bar-c-5, + --bar-c-6, --bar-c-7, --bar-c-8, + --bar-c-9, --bar-c-10, --bar-c-11, + --bar-c-12, --bar-c-13, --bar-c-14, + --bar-c-15, --bar-c-16, --bar-c-17, + --bar-c-18, --bar-c-19, --bar-c-20, + --bar-c-21, --bar-c-22, --bar-c-23, + --bar-c-24, --bar-c-25, --bar-c-26, + --bar-c-27, --bar-c-28, --bar-c-29, + --bar-c-30, --bar-c-31, --bar-c-32, + --bar-c-33, --bar-c-34, --bar-c-35, + --bar-c-36, --bar-c-37, --bar-c-38, + --bar-c-39, --bar-c-40, --bar-c-41, + --bar-c-42, --bar-c-43, --bar-c-44, + --bar-c-45, --bar-c-46, --bar-c-47, + --bar-c-48, --bar-c-49, --bar-c-50, + --bar-c-51, --bar-c-52, --bar-c-53, + --bar-c-54, --bar-c-55, --bar-c-56, + --bar-c-57, --bar-c-58, --bar-c-59, + --bar-c-60, --bar-c-61, --bar-c-62, + --bar-c-63, --bar-c-64, --bar-c-65, + --bar-c-66, --bar-c-67, --bar-c-68, + --bar-c-69, --bar-c-70, --bar-c-71, + --bar-c-72, --bar-c-73, --bar-c-74, + --bar-c-75, --bar-c-76, --bar-c-77, + --bar-c-78, --bar-c-79, --bar-c-80, + --bar-c-81, --bar-c-82, --bar-c-83, + --bar-c-84, --bar-c-85, --bar-c-86, + --bar-c-87, --bar-c-88, --bar-c-89, + --bar-c-90, --bar-c-91, --bar-c-92, + --bar-c-93, --bar-c-94, --bar-c-95, + --bar-c-96, --bar-c-97, --bar-c-98, + --bar-c-99; + } + +@property --point-played { + syntax: ""; + inherits: false; + initial-value: rgb(110, 231, 183); +} + +@property --point-buffered { + syntax: ""; + inherits: false; + initial-value: rgb(156, 163, 175); +} + +/* Define properties */ +@property --bar-c-0 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-1 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-2 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-3 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-4 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-5 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-6 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-7 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-8 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-9 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-10 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-11 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-12 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-13 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-14 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-15 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-16 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-17 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-18 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-19 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-20 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-21 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-22 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-23 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-24 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-25 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-26 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-27 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-28 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-29 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-30 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-31 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-32 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-33 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-34 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-35 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-36 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-37 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-38 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-39 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-40 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-41 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-42 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-43 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-44 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-45 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-46 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-47 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-48 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-49 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-50 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-51 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-52 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-53 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-54 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-55 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-56 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-57 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-58 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-59 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-60 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-61 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-62 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-63 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-64 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-65 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-66 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-67 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-68 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-69 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-70 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-71 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-72 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-73 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-74 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-75 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-76 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-77 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-78 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-79 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-80 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-81 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-82 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-83 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-84 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-85 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-86 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-87 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-88 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-89 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-90 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-91 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-92 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-93 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-94 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-95 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-96 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-97 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-98 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-99 { + syntax: ''; + inherits: false; + initial-value: transparent; + } diff --git a/osse-web/src/app/shared/player/player.component.html b/osse-web/src/app/shared/player/player.component.html new file mode 100644 index 0000000..f79a915 --- /dev/null +++ b/osse-web/src/app/shared/player/player.component.html @@ -0,0 +1,55 @@ +
+
+
+
+ + + +
+
+

+ {{trackTitle()}} +

+
+

{{artistTitle()}}

+
+
+
+
+
+ +
+
+ +
+
+
+
+

{{ currentTime() || '0:00' }}

+
+
+
+ +
+
+
+

{{ totalDuration() || '0:00' }}

+
+ +
+
+ + + +
diff --git a/osse-web/src/app/shared/player/player.component.ts b/osse-web/src/app/shared/player/player.component.ts new file mode 100644 index 0000000..da0c547 --- /dev/null +++ b/osse-web/src/app/shared/player/player.component.ts @@ -0,0 +1,194 @@ +import { AfterViewInit, Component, ElementRef, signal, ViewChild, WritableSignal } from '@angular/core'; +import { PlayerService } from './player.service'; +import { PointState } from './point-state'; +import { RouterLink } from '@angular/router'; +import { IconComponent } from '../ui/icon/icon.component'; +import { mdiDotsVertical } from '@mdi/js'; +import { BufferUpdate } from './buffer-update.interface'; +import { getNicelyFormattedTime } from '../util/time'; +import { MediaSessionService } from './media-session.service'; +import { PopoverControlsComponent } from './popover-controls/popover-controls.component'; +import { CommonModule } from '@angular/common'; +import { TrackControlsComponent } from './track-controls/track-controls.component'; +import { PlayPauseComponent } from './track-controls/play-pause/play-pause.component'; + +@Component({ + selector: 'app-player', + imports: [PopoverControlsComponent, TrackControlsComponent, PlayPauseComponent, IconComponent, RouterLink, CommonModule], + templateUrl: './player.component.html', + styleUrl: `./player.component.css` +}) +export class PlayerComponent implements AfterViewInit { + @ViewChild('progressContainer') container!: ElementRef; + @ViewChild('point') point!: ElementRef; + @ViewChild('rendered') rendered!: ElementRef; + @ViewChild('trackTitleElement') trackTitleElement!: ElementRef; + @ViewChild('popoverControls') popoverControls!: ElementRef; + + public bg: WritableSignal = signal("assets/img/osse.webp"); + public currentTime: WritableSignal = signal(''); + public totalDuration: WritableSignal = signal(''); + public trackTitle: WritableSignal = signal(''); + public artistTitle: WritableSignal = signal(''); + private isDragging = false; + private abortMouseMove = new AbortController(); + private seekDuration = 0; + private resizeTimer = 0; + + verticalDots = mdiDotsVertical; + + constructor( + public playerService: PlayerService, + private mediaSessionService: MediaSessionService + ) { + // Make sure the mouse up is accessible in global contexts + this.onMouseUp = this.onMouseUp.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + + // Listen for the resize event + window.addEventListener('resize', () => { + this.queueResizeCheck(); + }) + } + + private queueResizeCheck() { + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + this.setTitleAnimationByScreenSize(); + }, 150); + } + + onBackdropClick(ev: any) { + if (ev.target.id == 'popover') { + this.popoverControls.nativeElement.close(); + } + } + + onMouseDown(ev: MouseEvent) { + this.isDragging = true; + ev.preventDefault(); + + this.setPointState(PointState.Pause); + + document.addEventListener('mousemove', this.onMouseMove, { signal: this.abortMouseMove.signal }); + document.addEventListener('mouseup', this.onMouseUp, { once: true }); + } + + onMouseMove(ev: MouseEvent) { + if (this.isDragging) { + const progressBarRect = this.container.nativeElement.getBoundingClientRect(); + const progressBarWidth = progressBarRect.width; + const newPositionX = ev.clientX - progressBarRect.left; + + // Ensure the new position is within the bounds of the progress bar + const clampedPositionX = Math.max(0, Math.min(progressBarWidth, newPositionX)); + + // Update the position of the progress point + this.point.nativeElement.style.left = clampedPositionX + 'px'; + this.seekDuration = (clampedPositionX / progressBarWidth); + } + } + + onMouseUp(_ev: any) { + this.abortMouseMove.abort(); + this.abortMouseMove = new AbortController(); + this.setPointState(PointState.Play); + this.playerService.play(this.seekDuration * this.playerService.duration()); + this.isDragging = false; + } + + onSetPosition(ev: MouseEvent) { + this.onMouseDown(ev); + this.onMouseMove(ev); + } + + setPointState(state: PointState) { + switch (state) { + case PointState.Pause: + this.point.nativeElement.classList.add('paused'); + this.point.nativeElement.classList.remove('playing'); + break; + case PointState.Play: + this.point.nativeElement.classList.add('playing'); + this.point.nativeElement.classList.remove('paused'); + break; + default: + break; + } + } + + setGradient(start: number, color: string, end?: number) { + if (end == undefined) { + this.rendered.nativeElement.style.setProperty('--bar-c-' + start, color); + } else { + for (let i = start; i < end; i++) { + this.rendered.nativeElement.style.setProperty('--bar-c-' + i, color); + } + } + } + + private onBufferProgress(bufferUpdate: BufferUpdate) { + const { duration, buffered } = bufferUpdate; + + this.point.nativeElement.style.animationDuration = duration + "s"; + + if (duration > 0) { + for (let i = 0; i < buffered.length; i++) { + let start = buffered.start(i) / duration * 100; + let end = buffered.end(i) / duration * 100; + this.setGradient(Math.floor(start), "var(--point-buffered)", Math.floor(end)); + } + } + } + + private setTitleAnimationByScreenSize() { + if (this.trackTitleElement.nativeElement.offsetWidth < this.trackTitleElement.nativeElement.parentElement!.offsetWidth) { + // Pause, set text to be visible + this.trackTitleElement.nativeElement.style.animationPlayState = 'paused'; + let anim = this.trackTitleElement.nativeElement.getAnimations()[0]; + let duration = anim.effect!.getTiming().duration; + anim.currentTime = (duration as number) / 2; + } else { + this.trackTitleElement.nativeElement.style.animationPlayState = 'running'; + } + } + + /** + * When the audio player is fully loaded, send the audio element to player service + */ + ngAfterViewInit(): void { + this.playerService.trackUpdated.subscribe((val) => { + this.totalDuration.set(val.durationFormatted); + this.trackTitle.set(val.title); + this.artistTitle.set(val.artist?.name ?? ''); + // Set the cover bg + this.bg.set(val.cover); + this.setTitleAnimationByScreenSize(); + this.setGradient(0, "transparent", 100); + this.queueResizeCheck(); + }); + + this.playerService.trackPositionUpdate.subscribe((val) => { + this.currentTime.set(getNicelyFormattedTime(val.currentTimeSeconds)) + + // If the user is not seeking, update the position + if (!this.isDragging) { + this.point.nativeElement.style.left = Math.floor((val.currentTimeSeconds / val.totalTimeSeconds) * 100) + "%"; + } + + // Set the duration as we may have a more accurate total duration. + this.totalDuration.set(getNicelyFormattedTime(val.totalTimeSeconds)); + }); + + this.playerService.playbackEnded.subscribe(_ => { + this.totalDuration.set(''); + this.currentTime.set(''); + this.trackTitle.set(''); + this.artistTitle.set(''); + this.bg.set('assets/img/osse.webp'); + this.setGradient(0, "transparent", 100); + }); + + this.playerService.bufferUpdated.subscribe((ev) => this.onBufferProgress(ev)); + } +} diff --git a/osse-web/src/app/shared/player/player.service.ts b/osse-web/src/app/shared/player/player.service.ts new file mode 100644 index 0000000..812f761 --- /dev/null +++ b/osse-web/src/app/shared/player/player.service.ts @@ -0,0 +1,220 @@ +import { EventEmitter, Injectable, signal, WritableSignal } from '@angular/core'; +import { Track } from '../services/track/track'; +import { TrackPlayerInfo, TrackUpdate } from './track-update'; +import { PlaybackState } from './state-change'; +import { ConfigService } from '../services/config/config.service'; +import { BackgroundImageService } from '../ui/background-image.service'; +import { BufferUpdate } from './buffer-update.interface'; +import { TrackPosition } from './track-position.interface'; +import { WebAudioService } from './web-audio.service'; +import { ToastService } from '../../toast-container/toast.service'; +import { fetcher } from '../util/fetcher'; + +@Injectable({ + providedIn: 'root' +}) +export class PlayerService { + /** + * Runs whenever a track is changed. + * This could be a new track, or just loading more buffer data + */ + public trackUpdated = new EventEmitter(); + public trackPositionUpdate = new EventEmitter(); + public stateChanged = new EventEmitter(); + public playbackEnded = new EventEmitter(); + public bufferUpdated = new EventEmitter(); + private audioPlayer = new Audio(); + private track!: Track | null; + private playbackRate: number = 1; + + private durationSignal: WritableSignal = signal(0); + private currenTimeSignal: WritableSignal = signal(0); + private isPlayingSignal: WritableSignal = signal(false); + + constructor( + private configService: ConfigService, + private backgroundImageService: BackgroundImageService, + private webAudioService: WebAudioService, + private notificationService: ToastService + ) { + // Set up web audio + // Cross origin is anonymous becuase it is a different origin, but we don't use credentials (cookies). + this.audioPlayer.crossOrigin = "anonymous"; + this.webAudioService.setUp(this.audioPlayer); + + this.audioPlayer.addEventListener('timeupdate', (_ev) => { + this.currenTimeSignal.set(this.audioPlayer.currentTime); + this.trackPositionUpdate.emit({ + currentTimeSeconds: this.audioPlayer.currentTime, + totalTimeSeconds: Math.max(this.track?.duration ?? 0, isNaN(this.audioPlayer.duration) ? 0 : this.audioPlayer.duration) + }); + }); + + this.audioPlayer.addEventListener('play', (_ev) => { + this.isPlayingSignal.set(true) + this.stateChanged.emit(PlaybackState.Playing); + this.webAudioService.resumeIfSuspended(); + }); + this.audioPlayer.addEventListener('pause', (_ev) => { + this.isPlayingSignal.set(false); + this.stateChanged.emit(PlaybackState.Paused); + }); + this.audioPlayer.addEventListener('ended', (_ev) => { + this.isPlayingSignal.set(false); + this.playbackEnded.emit(); + }); + this.audioPlayer.addEventListener('progress', (_ev) => { + this.durationSignal.update((oldDuration: number) => { + if (isNaN(this.audioPlayer.duration)) { + return oldDuration; + } + + return Math.max(oldDuration, this.audioPlayer.duration); + }); + + this.bufferUpdated.emit({ duration: this.duration(), buffered: this.audioPlayer.buffered }) + }); + this.audioPlayer.addEventListener('error', (_ev) => { + this.notificationService.error('An error occurred while loading the audio file.'); + }); + + this.audioPlayer.preload = "metadata"; + + this.playbackRate = Number(localStorage.getItem('speed') ?? 1); + this.audioPlayer.volume = Number(localStorage.getItem('volume') ?? 1); + } + + public async setTrack(track: Track) { + // Set next track + this.track = track; + + // Set the real duration. Used for calculating buffer percentages later. + // Not all formats list the end duration at the start of the track + this.durationSignal.set(track.duration); + + // Get a token to access the file from the file server. + let req = await fetcher('tracks/' + track.id + '/stream?v=' + track.scannedAt); + if (req.ok) { + this.trackUpdated.emit(new TrackUpdate(this.track, this.buildTrackInfo())); + document.title = "Osse - " + this.track.title; + + let res = await req.json(); + let token = res.token; + let url = res.url; + + this.audioPlayer.src = url + '?token=' + token + '&id=' + this.configService.get('userID') + '&trackID=' + track.id; + // The playback rate is reset when a new track is loaded, set it again. + this.audioPlayer.playbackRate = this.playbackRate; + } else { + this.notificationService.error('Failed to play track.'); + } + } + + public async setTrackAndPlay(track: Track, duration: number = 0) { + await this.setTrack(track); + await this.play(); + + // We do this last. It may slow down the player if it is first since it makes a network request. + // Browsers are async, but our server isn't (yet). + this.setBackgroundImage(); + } + + public setTrackAndBackgroundImage(track: Track) { + this.setTrack(track); + this.setBackgroundImage(); + } + + public setDuration(duration: number) { + this.audioPlayer.currentTime = duration; + } + + private setBackgroundImage() { + this.backgroundImageService.setBG(this.track!.coverURL); + } + + public play(time: number = this.audioPlayer.currentTime) { + this.audioPlayer.currentTime = time; + return new Promise((resolve) => { + this.audioPlayer.play() + .then(resolve) + .catch(resolve); + }); + } + + public pause() { + this.audioPlayer.pause(); + } + + public toggle() { + if (this.isPlayingSignal()) { + this.pause(); + } else { + this.play(); + } + } + + private buildTrackInfo(): TrackPlayerInfo { + return { + time: this.audioPlayer.currentTime, + totalDurationEstimate: this.audioPlayer.duration + } + } + + public clearTrack() { + this.audioPlayer.removeAttribute('src'); + this.audioPlayer.currentTime = 0; + this.track = null; + this.isPlayingSignal.set(false); + this.playbackEnded.emit(); + } + + public setVolume(vol: number) { + this.audioPlayer.volume = vol; + } + + public getVolume(): number { + return this.audioPlayer.volume; + } + + public setSpeed(speed: number) { + this.playbackRate = speed; + this.audioPlayer.playbackRate = this.playbackRate; + } + + public getSpeed() { + return this.playbackRate; + } + + /** + * Skips forard or back in a song. + * Handles going past the end or beggining of the song + */ + public jumpDuration(duration: number, jumpForward = true) { + if (jumpForward) { + this.seek(this.audioPlayer.currentTime + duration); + } else { + this.seek(this.audioPlayer.currentTime - duration); + } + } + + private seek(duration: number) { + // @ts-ignore This is valid because browser support for this function isn't good + if (this.audioPlayer.fastSeek) { + this.audioPlayer.fastSeek(duration); + } else { + this.audioPlayer.currentTime = duration; + } + } + + get duration() { + return this.durationSignal.asReadonly(); + } + + get currentTime() { + return this.currenTimeSignal.asReadonly(); + } + + get isPlaying() { + return this.isPlayingSignal.asReadonly(); + } +} diff --git a/osse-web/src/app/shared/player/point-state.ts b/osse-web/src/app/shared/player/point-state.ts new file mode 100644 index 0000000..a84b631 --- /dev/null +++ b/osse-web/src/app/shared/player/point-state.ts @@ -0,0 +1,4 @@ +export enum PointState { + Play, + Pause +} \ No newline at end of file diff --git a/osse-web/src/app/shared/player/popover-controls/popover-controls.component.html b/osse-web/src/app/shared/player/popover-controls/popover-controls.component.html new file mode 100644 index 0000000..9631f05 --- /dev/null +++ b/osse-web/src/app/shared/player/popover-controls/popover-controls.component.html @@ -0,0 +1,40 @@ +
+

Advanced Controls

+ +
+
+

Duration

+ +
+ +
+

Rewind/Fast Forward

+ +
+ +
+

Volume

+ +
+ +
+

Pan (L/R)

+ +
+ +
+

Speed (0-2)

+ +
+ +
+

Track Controls

+ +
+
+

Clear Queue

+ +
+
+
diff --git a/osse-web/src/app/shared/player/popover-controls/popover-controls.component.ts b/osse-web/src/app/shared/player/popover-controls/popover-controls.component.ts new file mode 100644 index 0000000..c7f4aee --- /dev/null +++ b/osse-web/src/app/shared/player/popover-controls/popover-controls.component.ts @@ -0,0 +1,21 @@ +import { Component, output } from '@angular/core'; +import { VolumeComponent } from '../volume/volume.component'; +import { TrackControlsComponent } from '../track-controls/track-controls.component'; +import { DurationComponent } from '../duration/duration.component'; +import { IconComponent } from '../../ui/icon/icon.component'; +import { mdiClose } from '@mdi/js'; +import { JumpControlsComponent } from '../jump-controls/jump-controls.component'; +import { PanControlsComponent } from '../pan-controls/pan-controls.component'; +import { SpeedControlsComponent } from '../speed-controls/speed-controls.component'; +import { ClearQueueControlsComponent } from "../clear-queue-controls/clear-queue-controls.component"; + +@Component({ + selector: 'app-popover-controls', + imports: [VolumeComponent, TrackControlsComponent, DurationComponent, JumpControlsComponent, PanControlsComponent, SpeedControlsComponent, IconComponent, ClearQueueControlsComponent], + templateUrl: './popover-controls.component.html', + styles: `` +}) +export class PopoverControlsComponent { + public onClose = output(); + close = mdiClose; +} diff --git a/osse-web/src/app/shared/player/preload/preload.service.ts b/osse-web/src/app/shared/player/preload/preload.service.ts new file mode 100644 index 0000000..fe823e5 --- /dev/null +++ b/osse-web/src/app/shared/player/preload/preload.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { PlayerService } from '../player.service'; +import { TrackService } from '../../services/track/track.service'; +import { ConfigService } from '../../services/config/config.service'; +import { fetcher } from '../../util/fetcher'; + +@Injectable({ + providedIn: 'root' +}) +export class PreloadService { + private audioPlayer = new Audio(); + private isPreloadingTrack = false; + + constructor(private playerService: PlayerService, private trackService: TrackService, private configService: ConfigService) { + // Setup the preload element. + this.audioPlayer.muted = true; + this.audioPlayer.autoplay = false; + this.audioPlayer.preload = 'metadata'; + this.audioPlayer.crossOrigin = "anonymous"; + this.audioPlayer.addEventListener('loadedmetadata', () => { + this.audioPlayer.pause(); + }); + + this.playerService.trackPositionUpdate.subscribe((t) => { + // Only check preload if we are not preloading something. + if (this.isPreloadingTrack) { + return; + } + + // If we are 80% through the track, start next preload. + if ((t.currentTimeSeconds / t.totalTimeSeconds) >= 0.8) { + this.preloadNextTrack(); + } + }); + + // On track update, we mark the preload element as able to preload a new track. + this.playerService.trackUpdated.subscribe((t) => { + this.isPreloadingTrack = false; + }); + } + + private async preloadNextTrack() { + this.isPreloadingTrack = true; + + // Get the next track (if any) + let track = this.trackService.getUpcomingTrack(); + if (track) { + // Request authorization to preload. + let req = await fetcher('tracks/' + track.id + '/stream?v=' + track.scannedAt); + if (req.ok) { + let res = await req.json(); + let token = res.token; + let url = res.url; + + this.audioPlayer.src = url + '?token=' + token + '&id=' + this.configService.get('userID') + '&trackID=' + track.id; + this.audioPlayer.load(); + } + } + } +} diff --git a/osse-web/src/app/shared/player/speed-controls/speed-controls.component.html b/osse-web/src/app/shared/player/speed-controls/speed-controls.component.html new file mode 100644 index 0000000..5d4d011 --- /dev/null +++ b/osse-web/src/app/shared/player/speed-controls/speed-controls.component.html @@ -0,0 +1,13 @@ +
+ + +
+

0

+ +

2

+
+
diff --git a/osse-web/src/app/shared/player/speed-controls/speed-controls.component.ts b/osse-web/src/app/shared/player/speed-controls/speed-controls.component.ts new file mode 100644 index 0000000..4588157 --- /dev/null +++ b/osse-web/src/app/shared/player/speed-controls/speed-controls.component.ts @@ -0,0 +1,55 @@ +import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; +import { mdiRestart } from '@mdi/js'; +import { PlayerService } from '../player.service'; +import { IconComponent } from '../../ui/icon/icon.component'; + +@Component({ + selector: 'app-speed-controls', + imports: [IconComponent], + templateUrl: './speed-controls.component.html', + styles: `` +}) +export class SpeedControlsComponent implements AfterViewInit { + @ViewChild('speed') speedInput!: ElementRef; + reset = mdiRestart; + + constructor(private playerService: PlayerService) { } + + setInitialSpeed(): void { + this.storeAndSetSpeed(Number(localStorage.getItem('speed') ?? 1)); + this.speedInput.nativeElement.value = String(this.playerService.getSpeed()); + } + + onSpeedChange(event: any) { + this.storeAndSetSpeed(event.target.value); + } + + adjustSpeedByScroll(event: any) { + event.preventDefault(); + let currentSpeed = this.playerService.getSpeed(); + + let newSpeed; if (event.deltaY > 0) { + newSpeed = Math.max(0, currentSpeed - 0.1); + } else { + newSpeed = Math.min(2, currentSpeed + 0.1); + } + + this.speedInput.nativeElement.value = String(newSpeed); + this.storeAndSetSpeed(newSpeed); + } + + onSpeedReset() { + this.speedInput.nativeElement.value = "1"; + // The event isn't triggered. + this.storeAndSetSpeed(1); + } + + private storeAndSetSpeed(speed: number) { + this.playerService.setSpeed(speed); + localStorage.setItem('speed', speed.toString()); + } + + ngAfterViewInit(): void { + this.setInitialSpeed(); + } +} diff --git a/osse-web/src/app/shared/player/state-change.ts b/osse-web/src/app/shared/player/state-change.ts new file mode 100644 index 0000000..5cbf2b8 --- /dev/null +++ b/osse-web/src/app/shared/player/state-change.ts @@ -0,0 +1,4 @@ +export enum PlaybackState { + Paused, + Playing +} \ No newline at end of file diff --git a/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.html b/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.html new file mode 100644 index 0000000..6549638 --- /dev/null +++ b/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.html @@ -0,0 +1,3 @@ + diff --git a/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.ts b/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.ts new file mode 100644 index 0000000..c0d544f --- /dev/null +++ b/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.ts @@ -0,0 +1,23 @@ +import { Component, computed } from '@angular/core'; +import { TrackService } from '../../../services/track/track.service'; +import { PlayerService } from '../../player.service'; +import { IconComponent } from '../../../ui/icon/icon.component'; +import { mdiPause, mdiPlay } from '@mdi/js'; + +@Component({ + selector: 'app-play-pause', + imports: [IconComponent], + templateUrl: './play-pause.component.html', + styles: `` +}) +export class PlayPauseComponent { + public playerIcon = computed(() => this.playerService.isPlaying() ? mdiPause : mdiPlay); + + constructor(private trackService: TrackService, private playerService: PlayerService) { } + public onPlayerToggle() { + // If no track, don't respond to button click + if (!this.trackService.activeTrack) return; + + this.playerService.toggle(); + } +} diff --git a/osse-web/src/app/shared/player/track-controls/track-controls.component.html b/osse-web/src/app/shared/player/track-controls/track-controls.component.html new file mode 100644 index 0000000..e9c6029 --- /dev/null +++ b/osse-web/src/app/shared/player/track-controls/track-controls.component.html @@ -0,0 +1,9 @@ +
+ + + +
diff --git a/osse-web/src/app/shared/player/track-controls/track-controls.component.ts b/osse-web/src/app/shared/player/track-controls/track-controls.component.ts new file mode 100644 index 0000000..dff8adf --- /dev/null +++ b/osse-web/src/app/shared/player/track-controls/track-controls.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { IconComponent } from '../../ui/icon/icon.component'; +import { mdiFastForward, mdiRewind } from '@mdi/js'; +import { TrackService } from '../../services/track/track.service'; +import { PlayPauseComponent } from './play-pause/play-pause.component'; + +@Component({ + selector: 'app-track-controls', + imports: [IconComponent, PlayPauseComponent], + templateUrl: './track-controls.component.html', + styles: `` +}) +export class TrackControlsComponent { + forward = mdiFastForward; + back = mdiRewind; + + constructor(private trackService: TrackService) { } + + public onNextTrack() { + this.trackService.moveToNextTrack(); + } + + public onPreviousTrack() { + this.trackService.moveToLastTrack(); + } +} diff --git a/osse-web/src/app/shared/player/track-position.interface.ts b/osse-web/src/app/shared/player/track-position.interface.ts new file mode 100644 index 0000000..8b3e479 --- /dev/null +++ b/osse-web/src/app/shared/player/track-position.interface.ts @@ -0,0 +1,4 @@ +export interface TrackPosition { + currentTimeSeconds: number; + totalTimeSeconds: number; +} diff --git a/osse-web/src/app/shared/player/track-update.ts b/osse-web/src/app/shared/player/track-update.ts new file mode 100644 index 0000000..d4b4380 --- /dev/null +++ b/osse-web/src/app/shared/player/track-update.ts @@ -0,0 +1,53 @@ +import { Track } from "../services/track/track"; +import { getNicelyFormattedTime } from "../util/time"; + +export class TrackUpdate { + constructor(private track: Track, private info: TrackPlayerInfo) { } + + get totalSeconds() { + return this.track.duration; + } + + get currentSecond() { + return this.info.time; + } + + get title() { + return this.track.title; + } + + get artist() { + if (this.track.hasArtist()) { + return this.track.artistPrimary(); + } + + return null; + } + + get durationFormatted() { + // Sometimes the audio player duration estimate is undetectable. Return the metadata duration in that case. + if (isNaN(this.info.totalDurationEstimate)) { + return getNicelyFormattedTime(this.track.duration + 1); + } else { + // return the metadata duration or the audio duration, whichever is bigger. + return getNicelyFormattedTime(Math.max(this.track.duration + 1, this.info.totalDurationEstimate)); + } + } + + get timeFormatted() { + return getNicelyFormattedTime(this.info.time); + } + + get id() { + return this.track.track.id; + } + + get cover() { + return this.track.coverURL; + } +} + +export interface TrackPlayerInfo { + time: number; + totalDurationEstimate: number; +} diff --git a/osse-web/src/app/shared/player/visualizer/visualizer.component.html b/osse-web/src/app/shared/player/visualizer/visualizer.component.html new file mode 100644 index 0000000..a9baed8 --- /dev/null +++ b/osse-web/src/app/shared/player/visualizer/visualizer.component.html @@ -0,0 +1,2 @@ + + diff --git a/osse-web/src/app/shared/player/visualizer/visualizer.component.ts b/osse-web/src/app/shared/player/visualizer/visualizer.component.ts new file mode 100644 index 0000000..29376ee --- /dev/null +++ b/osse-web/src/app/shared/player/visualizer/visualizer.component.ts @@ -0,0 +1,85 @@ +import { Component, ElementRef, ViewChild, OnInit, OnDestroy } from '@angular/core'; +import { WebAudioService } from '../web-audio.service'; + +@Component({ + selector: 'app-visualizer', + templateUrl: './visualizer.component.html', +}) +export class VisualizerComponent implements OnInit, OnDestroy { + @ViewChild('canvas', { static: true }) canvas!: ElementRef; + private ctx!: CanvasRenderingContext2D; + private animationFrameId!: number; + private width = 0; + private height = 0; + private resizeObserver!: () => void; + private resizeTimeout = 0; + + constructor(private webAudioService: WebAudioService) { } + + ngOnInit() { + this.ctx = this.canvas.nativeElement.getContext('2d')!; + + // Listen for resize events to rescale canvas + this.resizeObserver = () => { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => this.resizeCanvas(), 300); + } + window.addEventListener('resize', this.resizeObserver); + window.addEventListener('fullscreenchange', () => this.resizeCanvas()); + + this.resizeCanvas(); + this.drawVisualizer(); + } + + private resizeCanvas() { + const canvas = this.canvas.nativeElement; + const dpr = window.devicePixelRatio || 1; + + // Set canvas size based on client size and DPR + canvas.width = canvas.clientWidth * dpr; + canvas.height = canvas.clientHeight * dpr; + // Store local copies for easier access in drawing functions + this.width = canvas.width * dpr; + this.height = canvas.height * dpr; + + // Scale context so drawing operations match the high resolution + this.ctx.resetTransform(); // Reset to avoid cumulative scaling + this.ctx.scale(dpr, dpr); + } + + private drawVisualizer() { + this.ctx.clearRect(0, 0, this.width, this.height); + + const draw = () => { + this.animationFrameId = requestAnimationFrame(draw); + this.ctx.clearRect(0, 0, this.width, this.height); + + this.drawFrequencyBars(); + }; + draw(); + } + + private drawFrequencyBars() { + const data = this.webAudioService.getFrequencyData(); + const barWidth = this.canvas.nativeElement.width / data.length; + + data.forEach((value, i) => { + const barHeight = (value / 255) * this.canvas.nativeElement.height; + + // Create gradient from bottom (green) to top (light green) + const gradient = this.ctx.createLinearGradient(0, this.canvas.nativeElement.height - barHeight, 0, this.canvas.nativeElement.height); + gradient.addColorStop(0, 'rgb(52, 211, 153)'); // Bottom color + gradient.addColorStop(1, 'rgb(167, 243, 208)'); // Top color + + this.ctx.fillStyle = gradient; + this.ctx.fillRect(i * barWidth, this.canvas.nativeElement.height - barHeight, barWidth, barHeight); + }); + } + + ngOnDestroy() { + cancelAnimationFrame(this.animationFrameId); + clearTimeout(this.resizeTimeout); + window.removeEventListener('resize', this.resizeObserver); + window.removeEventListener('fullscreenchange', () => this.resizeCanvas()); + } +} diff --git a/osse-web/src/app/shared/player/volume/volume.component.html b/osse-web/src/app/shared/player/volume/volume.component.html new file mode 100644 index 0000000..d397860 --- /dev/null +++ b/osse-web/src/app/shared/player/volume/volume.component.html @@ -0,0 +1,8 @@ +
+ + + +
diff --git a/osse-web/src/app/shared/player/volume/volume.component.ts b/osse-web/src/app/shared/player/volume/volume.component.ts new file mode 100644 index 0000000..b31b1b8 --- /dev/null +++ b/osse-web/src/app/shared/player/volume/volume.component.ts @@ -0,0 +1,90 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, ViewChild, WritableSignal, signal } from '@angular/core'; +import { PlayerService } from '../player.service'; +import { mdiVolumeOff, mdiVolumeLow, mdiVolumeHigh } from '@mdi/js'; +import { IconComponent } from '../../ui/icon/icon.component'; + + +@Component({ + selector: 'app-volume', + imports: [IconComponent], + templateUrl: './volume.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class VolumeComponent implements AfterViewInit { + @ViewChild('volume') volumeInput!: ElementRef; + volumeIcon = signal(mdiVolumeHigh); + public showVolumeMenu: WritableSignal = signal(false); + + constructor(private playerService: PlayerService) { } + + setInitialVolume(): void { + this.storeAndSetVolume(Number(localStorage.getItem('volume') ?? 1)); + this.volumeInput.nativeElement.value = String(this.playerService.getVolume()); + } + + onVolumeChange(event: any) { + this.storeAndSetVolume(event.target.value); + } + + adjustVolumeByScroll(event: any) { + event.preventDefault(); + let currentVolume = this.playerService.getVolume(); + + let newVolume; + if (event.deltaY > 0) { + newVolume = Math.max(0, currentVolume - 0.05); + } else { + newVolume = Math.min(1, currentVolume + 0.05); + } + + this.volumeInput.nativeElement.value = String(newVolume); + this.storeAndSetVolume(newVolume); + this.setVolumeIcon(); + } + + onVolumeSet() { + this.setVolumeIcon(); + this.showVolumeMenu.set(false); + } + + setVolumeIcon() { + let volume = this.playerService.getVolume(); + if (volume == 0) { + this.volumeIcon.set(mdiVolumeOff); + } else { + if (volume <= 0.5) { + this.volumeIcon.set(mdiVolumeLow); + } else { + this.volumeIcon.set(mdiVolumeHigh); + } + } + } + + onMuteToggle() { + let volume = this.playerService.getVolume(); + if (volume == 0) { + this.storeAndSetVolume(0.5); + } else { + this.storeAndSetVolume(0); + } + + this.setVolumeIcon(); + this.volumeInput.nativeElement.value = String(this.playerService.getVolume()); + } + + public toggleMenu() { + this.showVolumeMenu.set(!this.showVolumeMenu()); + } + + private storeAndSetVolume(volume: number) { + this.playerService.setVolume(volume); + localStorage.setItem('volume', volume.toString()); + } + + ngAfterViewInit(): void { + this.playerService.stateChanged.subscribe((_v) => this.setVolumeIcon()); + this.setInitialVolume(); + } +} + diff --git a/osse-web/src/app/shared/player/web-audio.service.ts b/osse-web/src/app/shared/player/web-audio.service.ts new file mode 100644 index 0000000..adc1a36 --- /dev/null +++ b/osse-web/src/app/shared/player/web-audio.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@angular/core'; +import { ConfigService } from '../services/config/config.service'; + +@Injectable({ + providedIn: 'root' +}) +export class WebAudioService { + private audioContext = new AudioContext(); + private panner = this.audioContext.createStereoPanner(); + private analyser = this.audioContext.createAnalyser(); + + constructor(private configService: ConfigService) { } + + public setUp(audioElement: HTMLAudioElement): HTMLAudioElement { + const source = this.audioContext.createMediaElementSource(audioElement); + + source.connect(this.analyser); + // Visualizer + this.analyser.connect(this.panner); + // Panning + this.panner.pan.value = 0; + this.panner.connect(this.audioContext.destination); + return audioElement; + } + + /** + * Call when playback starts. + * Web audio is init before user interaction. Some browsers suspend it until interaction. + * Once audio starts from a user interaction, we can resume it. + */ + public resumeIfSuspended() { + if (this.audioContext.state == 'suspended') { + this.audioContext.resume(); + } + } + + public setPan(pan: number) { + this.panner.pan.value = pan; + } + + public getPanValue(): number { + return this.panner.pan.value; + } + + public getFrequencyData(): Uint8Array { + const smoothFactor = this.configService.get('visualizerSamples'); + const rawData = new Uint8Array(this.analyser.frequencyBinCount); + this.analyser.getByteFrequencyData(rawData); + + // Downsample by averaging every `smoothFactor` values + const filteredData = new Uint8Array(rawData.length / smoothFactor); + for (let i = 0; i < filteredData.length; i++) { + let sum = 0; + for (let j = 0; j < smoothFactor; j++) { + sum += rawData[i * smoothFactor + j]; + } + filteredData[i] = sum / smoothFactor; + } + + return filteredData; + } +} diff --git a/osse-web/src/app/shared/services/album/Album.ts b/osse-web/src/app/shared/services/album/Album.ts new file mode 100644 index 0000000..f1e739e --- /dev/null +++ b/osse-web/src/app/shared/services/album/Album.ts @@ -0,0 +1,52 @@ +import { LocatorService } from "../../../locator.service"; +import { ApiService } from "../api.service"; +import { Artist } from "../artist/artist"; +import { Track } from "../track/track"; +import { OsseAlbum } from "./osse-album"; + +export class Album { + private trackList: Track[] = []; + private artistInfo: Artist[] = []; + private apiService: ApiService = LocatorService.injector.get(ApiService); + + constructor(public album: OsseAlbum) { + album.tracks?.forEach(track => { + this.trackList.push(new Track(track)); + }); + + this.getArtistIfExists(); + } + + public get id() { + return this.album.id; + } + + public get name() { + return this.album.name; + } + + public get tracks() { + return this.trackList; + } + + public get artist() { + return this.artistInfo; + } + + public get year() { + return this.album.year; + } + + private getArtistIfExists() { + // If we loaded the artists, init the Artist classes. + if (this.album.artists != null) { + this.artistInfo = this.album.artists.map((a) => new Artist(a)); + return; + } + + // If artists exist but were not loaded, load them async + for (let artistId of this.album.artist_ids ?? []) { + this.apiService.getArtist(artistId).then(val => this.artistInfo.push(val as Artist)); + } + } +} diff --git a/osse-web/src/app/shared/services/album/osse-album.ts b/osse-web/src/app/shared/services/album/osse-album.ts new file mode 100644 index 0000000..21fa71c --- /dev/null +++ b/osse-web/src/app/shared/services/album/osse-album.ts @@ -0,0 +1,15 @@ +import { OsseArtist } from "../artist/osse-artist"; +import { OsseTrack } from "../track/osse-track"; + +export interface OsseAlbum { + id: number; + name: string; + artist_ids: number[] | null; + tracks: OsseTrack[]; + year: number | null; + + /** + * Artist data. + */ + artists: OsseArtist[] | null; +} diff --git a/osse-web/src/app/shared/services/api.service.ts b/osse-web/src/app/shared/services/api.service.ts new file mode 100644 index 0000000..b1a2f3e --- /dev/null +++ b/osse-web/src/app/shared/services/api.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@angular/core'; +import { ConfigService } from './config/config.service'; +import { Track } from './track/track'; +import { OsseTrack } from './track/osse-track'; +import { Artist } from './artist/artist'; +import { Album } from './album/Album'; +import { fetcher } from '../util/fetcher'; + +@Injectable({ + providedIn: 'root' +}) +export class ApiService { + + constructor(private configService: ConfigService) { } + + public async getAllTracks(): Promise { + try { + let request = await fetch(`${this.configService.get('apiURL')}tracks/all`); + let response = await request.json(); + + return response.map((track: OsseTrack) => new Track(track)) + } catch (e) { + return []; + } + } + + public async getArtist(id: number): Promise { + let request = await fetcher(`artists/${id}`); + if (request.ok) { + let artist = await request.json(); + return new Artist(artist); + } else { + return null; + } + } + + public async getAlbumWithTracks(id: number): Promise { + let request = await fetch(`${this.configService.get('apiURL')}albums/${id}/tracks`); + if (request.ok) { + let album = await request.json(); + return new Album(album); + } else { + return null; + } + } + + public get url() { + return this.configService.get('apiURL') + '/api/'; + } +} diff --git a/osse-web/src/app/shared/services/artist/artist-store.service.ts b/osse-web/src/app/shared/services/artist/artist-store.service.ts new file mode 100644 index 0000000..efcc110 --- /dev/null +++ b/osse-web/src/app/shared/services/artist/artist-store.service.ts @@ -0,0 +1,39 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { Artist } from './artist'; + +@Injectable({ + providedIn: 'root' +}) +export class ArtistStoreService { + public artists: Artist[] = []; + public fetchingArists: number[] = []; + public artistFetched = new EventEmitter(); + + constructor() { } + + public getArtistById(id: number) { + return this.artists.find((a) => a.id == id); + } + + public setArtist(artist: Artist) { + if (this.artistIsLoaded(artist.id)) return; + + this.artists.push(artist); + } + + public artistIsLoaded(id: number) { + return this.artists.some((a) => a.id == id); + } + + public addFetchingArtist(id: number) { + this.fetchingArists.push(id); + } + + public removeFetchingArtist(id: number) { + this.fetchingArists = this.fetchingArists.filter((a) => a != id); + } + + public isFetchingArtist(id: number) { + return this.fetchingArists.includes(id); + } +} diff --git a/osse-web/src/app/shared/services/artist/artist.ts b/osse-web/src/app/shared/services/artist/artist.ts new file mode 100644 index 0000000..9e37162 --- /dev/null +++ b/osse-web/src/app/shared/services/artist/artist.ts @@ -0,0 +1,13 @@ +import { OsseArtist } from "./osse-artist"; + +export class Artist { + constructor(private artist: OsseArtist) { } + + public get name() { + return this.artist.name; + } + + public get id() { + return this.artist.id; + } +} diff --git a/osse-web/src/app/shared/services/artist/osse-artist.ts b/osse-web/src/app/shared/services/artist/osse-artist.ts new file mode 100644 index 0000000..1ab7977 --- /dev/null +++ b/osse-web/src/app/shared/services/artist/osse-artist.ts @@ -0,0 +1,4 @@ +export interface OsseArtist { + id: number; + name: string; +} \ No newline at end of file diff --git a/osse-web/src/app/shared/services/auth/auth.guard.ts b/osse-web/src/app/shared/services/auth/auth.guard.ts new file mode 100644 index 0000000..5149c4b --- /dev/null +++ b/osse-web/src/app/shared/services/auth/auth.guard.ts @@ -0,0 +1,20 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, RouterStateSnapshot, Router, CanActivateFn } from '@angular/router'; +import { AuthService } from './auth.service'; + +export const isLoggedIn: CanActivateFn = async ( + _next: ActivatedRouteSnapshot, + state: RouterStateSnapshot +) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (await authService.isAuthenticated()) { + // Allow access if user is logged in + return true; + } else { + // Redirect to login page if not authenticated + router.navigate(['/login'], { queryParams: { returnUrl: state.url } }); + return false; + } +}; diff --git a/osse-web/src/app/shared/services/auth/auth.interface.ts b/osse-web/src/app/shared/services/auth/auth.interface.ts new file mode 100644 index 0000000..7321607 --- /dev/null +++ b/osse-web/src/app/shared/services/auth/auth.interface.ts @@ -0,0 +1,16 @@ +/** + * A successful auth response. + */ +export interface AuthResponse { + /** + * User ID + */ + id: number, + username: string, + settings: UserSettings; +} + +export interface UserSettings { + id: number; + queue: boolean; +} diff --git a/osse-web/src/app/shared/services/auth/auth.service.ts b/osse-web/src/app/shared/services/auth/auth.service.ts new file mode 100644 index 0000000..7764a4e --- /dev/null +++ b/osse-web/src/app/shared/services/auth/auth.service.ts @@ -0,0 +1,112 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { fetcher } from '../../util/fetcher'; +import { EchoService } from '../echo/echo.service'; +import { AuthResponse } from './auth.interface'; +import { TrackService } from '../track/track.service'; +import { BackgroundImageService } from '../../ui/background-image.service'; +import { ConfigService } from '../config/config.service'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private isLoggedIn = false; + private statusChecked = false; + public authStateChanged = new EventEmitter(); + + constructor( + private echoService: EchoService, private trackService: TrackService, private backgroundImageService: BackgroundImageService, + private configService: ConfigService) { + // Check if we are logged in by requesting the current user. If this fails, we know we are not logged in. + this.checkLoginStatus(); + } + + /** + * Tries to login. Sets the status accordingly. + */ + public async checkLoginStatus() { + try { + await this.attemptLogin(); + this.statusChecked = true; + } catch (e) { + this.statusChecked = true; + this.isLoggedIn = false; + } + } + + /** + * Attempt to login. This will get a CSRF token and try to login as the user. + * If this fails (csrf or user request), we set as not logged in. + */ + public async attemptLogin() { + let req = await fetcher('user'); + if (req.ok) { + this.login(await req.json()); + } else { + throw "Login failure."; + } + } + + /** + * Checks if the user is authenticated. + * If the request has not been made, it will wait until it is made. + */ + isAuthenticated(): Promise { + return new Promise((resolve, _reject) => { + if (this.statusChecked) { + resolve(this.isLoggedIn); + } + + setTimeout(async () => { + let result = await this.isAuthenticated(); + resolve(result); + }, 1000); + }); + } + + /** + * Call this after logging in. + * The work is already done, this just lets the client routes work and subscribes to events. + */ + private login(userAuth: AuthResponse): void { + this.isLoggedIn = true; + + // Set the config to use any account level config (not in local storage.) + this.configService.overrideConfig({ + queue: userAuth.settings.queue, + userID: userAuth.id, + }); + + // Listen for events. + this.echoService.connect().then((_e) => { + this.echoService.listenForScanStarted(); + this.echoService.listenForScanProgressed(); + this.echoService.listenForScanCompleted(); + this.echoService.listenForScanError(); + this.echoService.listenForScanFailed(); + this.echoService.listenForScanCancelled(); + }).catch(() => { + console.error("Failed to connect to osse-broadcast. Live events will not be received!"); + }); + + // Request queue from server to resume. + if (this.configService.get('queue')) { + this.trackService.fetchQueueFromServer(); + } + + this.authStateChanged.emit(true); + } + + public async logout() { + this.trackService.clearTracks(); + this.backgroundImageService.clearBG(); + this.echoService.disconnect(); + await fetcher('logout', { method: 'POST' }); + // Delete xsrf token. + document.cookie = "XSRF-TOKEN=;expires=" + new Date(0).toUTCString(); + + this.isLoggedIn = false; + this.authStateChanged.emit(false); + } +} + diff --git a/osse-web/src/app/shared/services/config/config.service.ts b/osse-web/src/app/shared/services/config/config.service.ts new file mode 100644 index 0000000..d807239 --- /dev/null +++ b/osse-web/src/app/shared/services/config/config.service.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { OsseConfig } from './config'; +import { getCookie } from '../../util/fetcher'; + +@Injectable({ + providedIn: 'root' +}) +export class ConfigService { + private config!: OsseConfig; + + constructor() { + this.initConfig(); + + // Get the ENV and populate any variables. Localstorage has priority + this.config = { + apiURL: localStorage.getItem('apiURL') ?? environment.apiURL, + version: environment.version, + showCoverBackgrounds: Boolean(localStorage.getItem('showCoverBackgrounds') ?? environment.showCoverBackgrounds), + showVisualizer: Boolean(localStorage.getItem('showVisualizer') ?? environment.showVisualizer), + visualizerSamples: Number(localStorage.getItem('visualizerSamples') ?? environment.visualizerSamples), + + // Below keys are sources from the server so we give them default values. + // They shouldn't be read until we get the user settings in the login process. + queue: true, + userID: -1, + }; + } + + public get(key: T, defaultVal?: any): OsseConfig[T] { + return this.config[key] ?? defaultVal ?? null; + } + + public save(key: T, val: OsseConfig[T]) { + localStorage.setItem(key, String(val)); + this.config[key] = val; + } + + /** + * Saves many entries into the config. + */ + public saveMany(conf: Partial) { + this.config = { ...this.config, ...conf }; + for (const key in conf) { + if (conf.hasOwnProperty(key)) { + localStorage.setItem(key, String(conf[key as keyof OsseConfig])); + } + } + } + + /** + * Sets all keys/values from an object to the current config. + * Keys that are in the current conf but not in the new one are unmodified. + * + * Used to populate account config. + */ + public overrideConfig(conf: Partial) { + this.config = { ...this.config, ...conf }; + } + + private initConfig() { + // If the user hasn't set an API URl, check what the server says the URL is. Fallback to that, or the env. + let userApiURL = localStorage.getItem('apiURL'); + if (!userApiURL) { + let serverSaysApiUrl = getCookie('API_URL'); + if (serverSaysApiUrl) { + localStorage.setItem('apiURL', serverSaysApiUrl.endsWith('/') ? serverSaysApiUrl : serverSaysApiUrl + '/'); + } else { + localStorage.setItem('apiURL', environment.apiURL); + } + } + } +} diff --git a/osse-web/src/app/shared/services/config/config.ts b/osse-web/src/app/shared/services/config/config.ts new file mode 100644 index 0000000..2a913ca --- /dev/null +++ b/osse-web/src/app/shared/services/config/config.ts @@ -0,0 +1,30 @@ +export interface OsseConfig { + /** + * Current version of the app. dev or x.x.x + */ + version: string, + /** + * API URL. + */ + apiURL: string; + /** + * Show album/track art in the background on certain pages. + */ + showCoverBackgrounds: boolean; + /** + * Show a music visualizer on the homepage. + */ + showVisualizer: boolean; + /** + * Amount of samples taken for the visualizer. + */ + visualizerSamples: number; + /** + * Enable/disable account queue + */ + queue: boolean; + /** + * ID of the user. + */ + userID: number; +} diff --git a/osse-web/src/app/shared/services/echo/channels/index.ts b/osse-web/src/app/shared/services/echo/channels/index.ts new file mode 100644 index 0000000..b1f2757 --- /dev/null +++ b/osse-web/src/app/shared/services/echo/channels/index.ts @@ -0,0 +1,15 @@ +import { ScanEventMap } from "./scan"; + +// Add to this as new events are created. +export type EchoEventMap = ScanEventMap; + +/** + * Every possible channel to subscribe to. + */ +export type EchoChannel = keyof EchoEventMap; + +/** + * Every possible result for an echo event. + */ +export type EchoResult = EchoEventMap[T]; + diff --git a/osse-web/src/app/shared/services/echo/channels/scan.ts b/osse-web/src/app/shared/services/echo/channels/scan.ts new file mode 100644 index 0000000..a07ac1b --- /dev/null +++ b/osse-web/src/app/shared/services/echo/channels/scan.ts @@ -0,0 +1,77 @@ +/** + * Listen for Scan Events. + */ +export interface ScanEvents { + listenForScanStarted(): void; + listenForScanProgressed(): void; + listenForScanCompleted(): void; + listenForScanError(): void; + listenForScanFailed(): void; + listenForScanCancelled(): void; +} + +export interface ScanStartedResult { + directories: ScanDirectory[]; +} + +export interface ScanDirectory { + id: number; + scanJobID: number; + path: string; + status: ScanDirectoryStatus; + filesScanned: number; + filesSkipped: number; +} + +export enum ScanDirectoryStatus { + Pending = 'pending', + Scanning = 'scanning', + Scanned = 'scanned', + Errored = 'errored', +} + +export interface ScanProgressedResult { + directoryID: number; + directoryName: string; + filesScanned: number; + filesSkipped: number; + status: ScanDirectoryStatus; +} + +export interface ScanCompletedResult { + directoryCount: number; +} + +export interface ScanErrorResult { + message: string; +} + +export interface ScanFailedResult { + message: string; +} + +export interface ScanCancelledResult { + directoriesScannedBeforeCancellation: number; +} + +export interface ScanEventMap { + ScanStarted: ScanStartedResult; + ScanProgressed: ScanProgressedResult; + ScanCompleted: ScanCompletedResult; + ScanError: ScanErrorResult; + ScanFailed: ScanFailedResult; + ScanCancelled: ScanCancelledResult; +} + + +/** + * Scan channel names to subscrbe to. + */ +export enum ScanChannels { + ScanStarted = "ScanStarted", + ScanProgressed = "ScanProgressed", + ScanCompleted = "ScanCompleted", + ScanError = "ScanError", + ScanFailed = "ScanFailed", + ScanCancelled = "ScanCancelled", +} diff --git a/osse-web/src/app/shared/services/echo/echo.service.ts b/osse-web/src/app/shared/services/echo/echo.service.ts new file mode 100644 index 0000000..cd05fa7 --- /dev/null +++ b/osse-web/src/app/shared/services/echo/echo.service.ts @@ -0,0 +1,89 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { fetcher } from '../../util/fetcher'; +import { ScanChannels, ScanEvents } from './channels/scan'; +import { EchoChannel, EchoResult } from './channels'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class EchoService implements ScanEvents { + private echoEvent = new EventEmitter<{ channel: EchoChannel; data: EchoResult }>(); + private eventSource!: EventSource; + + constructor() { } + + public connect() { + return new Promise((resolve, reject) => { + fetcher('sse', { + method: 'POST' + }).then(async (r) => { + if (r.ok) { + let json = await r.json(); + + // TODO: Use cookies instead of a query string to pass params. + this.eventSource = new EventSource(json.url + '?id=' + json.userID + '&token=' + json.token); + this.eventSource.addEventListener("error", (e) => console.log(e)); + // NOTE: To add a new event, you must suscribe by it to name. You can't use the generic "message" event. + resolve(null); + } + + }).catch(() => reject(null)); + }) + + } + + + public subscribeToEvent( + channel: T, + callback?: (data: EchoResult) => void + ): Observable> { + return new Observable>((observer) => { + const subscription = this.echoEvent.subscribe(({ channel: emittedChannel, data }) => { + if (emittedChannel === channel) { + const eventData = data as EchoResult; + observer.next(eventData); // Emit the data through the observable + if (callback) { + callback(eventData); // Optionally execute the callback + } + } + }); + + // Clean up when the observable is unsubscribed + return () => subscription.unsubscribe(); + }); + } + + private emitEvent(channel: T, data: EchoResult): void { + console.log(channel, data); + this.echoEvent.emit({ channel, data }); + } + + listenForScanStarted(): void { + this.eventSource.addEventListener("ScanStarted", (ev) => this.emitEvent(ScanChannels.ScanStarted, JSON.parse(ev.data))) + } + + listenForScanProgressed(): void { + this.eventSource.addEventListener("ScanProgressed", (ev) => this.emitEvent(ScanChannels.ScanProgressed, JSON.parse(ev.data))) + } + + listenForScanCompleted(): void { + this.eventSource.addEventListener("ScanCompleted", (ev) => this.emitEvent(ScanChannels.ScanCompleted, JSON.parse(ev.data))) + } + + listenForScanError(): void { + this.eventSource.addEventListener("ScanError", (ev) => this.emitEvent(ScanChannels.ScanError, JSON.parse(ev.data))) + } + + listenForScanFailed(): void { + this.eventSource.addEventListener("ScanFailed", (ev) => this.emitEvent(ScanChannels.ScanFailed, JSON.parse(ev.data))) + } + + listenForScanCancelled(): void { + this.eventSource.addEventListener("ScanCancelled", (ev) => this.emitEvent(ScanChannels.ScanCancelled, JSON.parse(ev.data))) + } + + public disconnect() { + this.eventSource.close(); + } +} diff --git a/osse-web/src/app/shared/services/network/network.service.ts b/osse-web/src/app/shared/services/network/network.service.ts new file mode 100644 index 0000000..9597302 --- /dev/null +++ b/osse-web/src/app/shared/services/network/network.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { ToastService } from '../../../toast-container/toast.service'; +import { ModalService } from '../../ui/modal/modal.service'; + +/** + * Class handles network operations. + */ +@Injectable({ + providedIn: 'root' +}) +export class NetworkService { + constructor(private notificationService: ToastService, private modelService: ModalService) { + window.addEventListener('online', () => { + this.notificationService.info('Internet connection restored.'); + }); + window.addEventListener('offline', () => { + this.notificationService.info('Internet connection lost.'); + }); + } +} diff --git a/osse-web/src/app/shared/services/playlist/Playlist.ts b/osse-web/src/app/shared/services/playlist/Playlist.ts new file mode 100644 index 0000000..e81af2d --- /dev/null +++ b/osse-web/src/app/shared/services/playlist/Playlist.ts @@ -0,0 +1,39 @@ +import { fetcher } from "../../util/fetcher"; +import { OsseTrack } from "../track/osse-track"; +import { Track } from "../track/track"; +import { OssePlaylist } from "./osse-playlist"; + +export class Playlist { + public tracks: Track[] = []; + + constructor(private playlist: OssePlaylist) { + if (playlist.tracks == undefined) { + return + } + this.tracks = playlist.tracks.map(t => { + return new Track(t); + }) + } + + public get id() { + return this.playlist.id; + } + + public get name() { + return this.playlist.name; + } + + public get count() { + return this.playlist.tracks_count; + } + + public async requestTracks() { + let req = await fetcher('playlists/' + this.id + '/tracks'); + if (req.ok) { + let res = await req.json(); + this.tracks = res.map((t: OsseTrack) => new Track(t)); + } + + return this.tracks; + } +} diff --git a/osse-web/src/app/shared/services/playlist/osse-playlist.ts b/osse-web/src/app/shared/services/playlist/osse-playlist.ts new file mode 100644 index 0000000..551cb20 --- /dev/null +++ b/osse-web/src/app/shared/services/playlist/osse-playlist.ts @@ -0,0 +1,9 @@ +import { OsseTrack } from "../track/osse-track"; + +export interface OssePlaylist { + id: number; + name: string; + tracks: OsseTrack[]; + // This is used when we don't load the track relation. + tracks_count: number; +} diff --git a/osse-web/src/app/shared/services/playlist/playlist.service.ts b/osse-web/src/app/shared/services/playlist/playlist.service.ts new file mode 100644 index 0000000..f39fb1c --- /dev/null +++ b/osse-web/src/app/shared/services/playlist/playlist.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { Playlist } from './Playlist'; +import { OssePlaylist } from './osse-playlist'; +import { fetcher } from '../../util/fetcher'; + +@Injectable({ + providedIn: 'root' +}) +export class PlaylistService { + constructor() { } + + public async getPlaylist(id: number) { + let req = await fetcher('playlists/' + id); + let res = await req.json(); + + if (req.ok) { + return new Playlist(res); + } + + throw "Playlist Error"; + } + + public async getAll(): Promise { + let req = await fetcher('playlists'); + let res = await req.json(); + + if (req.ok) { + return res.map((p: OssePlaylist) => new Playlist(p)); + } + + throw "Playlist Error"; + } + + public async addTrackToPlaylist(playlistId: number, trackId: number) { + await fetcher('playlists/' + playlistId + '/tracks/' + trackId, { + method: 'POST' + }); + } + + public addTracksToPlaylist(playlistId: number, trackIds: number[]) { + return fetcher('playlists/' + playlistId + '/track-set', { + method: 'POST', + body: JSON.stringify({ + 'track-ids': trackIds + }) + }); + } + + public createPlaylist(name: string) { + return fetcher('playlists', { + method: 'POST', + body: JSON.stringify({ + 'name': name + }) + }); + } +} diff --git a/osse-web/src/app/shared/services/track/osse-track.ts b/osse-web/src/app/shared/services/track/osse-track.ts new file mode 100644 index 0000000..42f91a1 --- /dev/null +++ b/osse-web/src/app/shared/services/track/osse-track.ts @@ -0,0 +1,20 @@ +import { OsseArtist } from "../artist/osse-artist"; + +export interface OsseTrack { + id: number; + title: string; + size: number; + duration: number; + + bitrate: number | null; + artist_ids: number[] | null; + track_number: number | null; + disc_number: number | null; + cover_art_id: number | null; + scanned_at: string; + + /** + * Artist data. A track can have an artist ID without loading the artist, but most queries load it. + */ + artists: OsseArtist[] | null; +} diff --git a/osse-web/src/app/shared/services/track/queue-sync.service.ts b/osse-web/src/app/shared/services/track/queue-sync.service.ts new file mode 100644 index 0000000..6b8ed40 --- /dev/null +++ b/osse-web/src/app/shared/services/track/queue-sync.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@angular/core'; +import { fetcher } from '../../util/fetcher'; +import { Track } from './track'; +import { OsseTrack } from './osse-track'; +import { ConfigService } from '../config/config.service'; + +@Injectable({ + providedIn: 'root' +}) +export class QueueSyncService { + private queueDebounce: number = 0; + + private lastSyncedIndex: number | null = null; + private lastSyncedPosition: number = 0; + private currentIndex: number | null = null; + private currentPosition: number = 0; + + constructor(private configService: ConfigService) { + setInterval(() => { + if ( + this.currentIndex !== this.lastSyncedIndex || + this.currentPosition !== this.lastSyncedPosition + ) { + this.lastSyncedIndex = this.currentIndex; + this.lastSyncedPosition = this.currentPosition; + + if (this.configService.get('queue')) { + fetcher('queue/active-track', { + method: 'POST', + body: JSON.stringify({ + active_track_index: this.currentIndex, + track_position: this.currentPosition + }) + }); + } + + } + }, 30000); + } + + /** + * Syncs the queue in 5 seconds, with debounce. + */ + syncQueue(trackIds: number[], trackIndex: number | null) { + if (this.configService.get('queue')) { + clearTimeout(this.queueDebounce); + this.queueDebounce = setTimeout(() => { + fetcher('queue', { + method: 'POST', + body: JSON.stringify({ + 'ids': trackIds, + // The active track is the input of the user if the track length is > 1, otherwise its null. + // Null symbolizes unknown curent track, or no tracks to choose from. + 'active_track': trackIds.length > 0 ? (trackIndex ?? 0) : null, + 'track_position': this.currentPosition, + }) + }) + }, 5000); + } + } + + /** + * Sets the active track and position server side. + * Syncs on an interval of 20 seconds. + */ + syncActiveTrack(trackIndex: number | null, trackPosition: number = 0) { + this.currentIndex = trackIndex; + this.currentPosition = Math.floor(trackPosition); + } + + async getQueueFromServer(): Promise { + if (!this.configService.get('queue')) { + return { + queue: [], + trackIndex: null, + trackPosition: 0 + }; + } + + let req = await fetcher('queue'); + if (req.ok) { + let result: QueueResponse = await req.json(); + if (result.trackIndex != null) { + return { + queue: result.tracks.map((t) => new Track(t)), + trackIndex: result.trackIndex, + trackPosition: result.trackPosition + } + } + } + + return { + queue: [], + trackIndex: null, + trackPosition: 0 + }; + } +} + +type QueueResponse = { + tracks: OsseTrack[]; + // Index of current track. + trackIndex: number | null; + // Seconds into current track. + trackPosition: number; +} + +type TrackQueue = { + queue: Track[]; + trackIndex: number | null; + trackPosition: number; +} diff --git a/osse-web/src/app/shared/services/track/repeat.enum.ts b/osse-web/src/app/shared/services/track/repeat.enum.ts new file mode 100644 index 0000000..81b607c --- /dev/null +++ b/osse-web/src/app/shared/services/track/repeat.enum.ts @@ -0,0 +1,8 @@ +/** + * Queue repeat values. None is default. + */ +export enum Repeat { + None = 0, + Once = 1, + Loop = 2, +} diff --git a/osse-web/src/app/shared/services/track/track.service.ts b/osse-web/src/app/shared/services/track/track.service.ts new file mode 100644 index 0000000..9c6e9fd --- /dev/null +++ b/osse-web/src/app/shared/services/track/track.service.ts @@ -0,0 +1,289 @@ +import { Injectable, signal, WritableSignal } from '@angular/core'; +import { Track } from './track'; +import { PlayerService } from '../../player/player.service'; +import { Repeat } from './repeat.enum'; +import { QueueSyncService } from './queue-sync.service'; + +/** + * This service stores all queued tracks + */ +@Injectable({ + providedIn: 'root' +}) +export class TrackService { + /** + * List of tracks in the queue. We can only call methods on it. DO NOT reset the value or the UI loses reference. + */ + public tracks: Track[] = []; + private index = 0; + private hasRepeatedCurrentTrack: boolean = false; + + /** + * List of cleared tracks. Used for restoration in case of accidental deletion. + */ + private clearedTracks: Track[] = []; + + /** + * If true, track is removed on end playback + */ + public consume: WritableSignal = signal(false); + public repeat: WritableSignal = signal(Repeat.None); + + constructor(private playerService: PlayerService, private queueSyncService: QueueSyncService) { + // When playback ends, wait 250 ms. + // We need the player to clear the UI first. It subscribes to the same event. + // Then, progress to the next track (if any) + this.playerService.playbackEnded.subscribe(_ => { + setTimeout(() => { + if (this.tracks.length <= 0) return; + + // Handle repeats. + let trackPlayedFromRepeat = this.playTrackBasedOnRepeatValue(); + if (trackPlayedFromRepeat) { + return; + } + + if (this.consume()) { + this.tracks.splice(this.index, 1); + if (this.tracks.length == 0) { + this.playerService.clearTrack(); + } else { + this.moveToLastTrack(); + } + } else { + this.moveToNextTrack(); + } + }, 250); + }) + + // Keep the server in synce with playback. There is a throttle of 20 seconds. + this.playerService.trackPositionUpdate.subscribe((pos) => { + this.queueSyncService.syncActiveTrack(this.index, pos.currentTimeSeconds); + }); + } + + get activeTrack() { + return this.tracks[this.index]; + } + + public addTrack(track: Track) { + // If the UUID is already in use, make a new uuid. + if (this.tracks.some((a) => a.uuid == track.uuid)) { + track = track.regenerateTrack(); + } + + this.tracks.push(track); + // If this is the first track added, start playback + if (this.tracks.length - 1 == 0) { + this.moveToTrack(0); + } + + this.queueSyncService.syncQueue(this.tracks.map((t) => t.id), this.index); + } + + // Removes all tracks and stops playback + public clearTracks() { + // Store a list of the cleared tracks. + this.clearedTracks = this.tracks.map((t) => t); + + while (this.tracks.length != 0) { + this.tracks.pop(); + } + + this.index = 0; + this.playerService.pause(); + this.playerService.clearTrack(); + + this.queueSyncService.syncQueue([], null); + } + + /** + * Called when the user leaves the homepage. + */ + public removeClearedTracks() { + this.clearedTracks = []; + } + + + public restoreTracks() { + if (this.clearedTracks.length == 0) { + return; + } + + // If we ever allow this method to be called outside of the homepage, we may need to clear any existing tracks first. + this.clearedTracks.forEach((t) => this.addTrack(t)); + this.clearedTracks = []; + + this.queueSyncService.syncQueue(this.tracks.map((t) => t.id), null); + } + + public moveToNextTrack() { + this.index += 1; + if (this.index == this.tracks.length) { + this.index = 0; + } + + if (this.tracks[this.index]) { + this.playerService.setTrackAndPlay(this.activeTrack); + // If user goes to next track, clear repeat tracker. + this.hasRepeatedCurrentTrack = false; + } + } + + public moveToLastTrack() { + this.index -= 1; + if (this.index < 0) { + this.index = 0; + } + + if (this.tracks[this.index]) { + this.playerService.setTrackAndPlay(this.activeTrack); + // If user goes to last track, clear repeat tracker. + this.hasRepeatedCurrentTrack = false; + } + } + + public moveToTrack(index: number) { + this.index = index; + + this.playerService.setTrackAndPlay(this.activeTrack); + } + + /** + * Same as moveToTrack, but it won't start playback. + */ + public setTrackIndex(index: number) { + this.index = index; + } + + public removeTrack(index: number) { + // If 1 track is present, remove them and end playback + if (this.tracks.length == 1) { + this.tracks.pop(); + this.index = 0; + + this.playerService.pause(); + this.playerService.clearTrack(); + return; + } + + // If the track is after the current track remove it + if (index > this.index) { + this.tracks.splice(index, 1); + } else if (index == this.index) { + // If its the current track, stop playback, remove it, and play next + this.tracks.splice(index, 1); + + this.playerService.pause(); + this.playerService.clearTrack(); + + // Reduce the index by one to play the "next" track + this.moveToLastTrack(); + } else { + // Track is before current, remove it and move index back 1 + this.tracks.splice(index, 1); + this.index -= 1; + } + + this.queueSyncService.syncQueue(this.tracks.map((t) => t.id), this.index); + } + + /** + * Shuffles the tracks and moves the index to the new location of the active track (if any) + */ + public shuffle() { + let currentTrack = this.activeTrack; + + this.tracks = this.tracks + .map(value => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value); + + if (currentTrack) { + this.index = this.tracks.findIndex((t) => t.id == currentTrack.id); + } + + this.queueSyncService.syncQueue(this.tracks.map((t) => t.id), this.index); + } + + /** + * Sets the queue to whatever the user has server side. + */ + public fetchQueueFromServer(play: boolean = false) { + // Move duration to 2nd method. + this.queueSyncService.getQueueFromServer() + .then((result) => { + result.queue.forEach(t => this.tracks.push(t)); + + if (this.tracks.length == 0) return; + + // If the session has a track, use it. + if (result.trackIndex != null) { + // There may have been a index desync, so make sure the index is valid. + if (!this.canMoveToTrack(result.trackIndex)) { + if (play) { + this.playerService.setTrackAndPlay(result.queue[0]); + } else { + this.playerService.setTrackAndBackgroundImage(result.queue[0]); + } + + // Set the track index so active track is correct. (it would play the right track, but the active track is wrong which causes UI issues.) + this.setTrackIndex(0); + + return; + } + + if (play) { + this.playerService.setTrackAndPlay(result.queue[result.trackIndex], result.trackPosition ?? 0); + } else { + this.playerService.setTrackAndBackgroundImage(result.queue[result.trackIndex]); + this.playerService.setDuration(result.trackPosition ?? 0); + } + + this.setTrackIndex(result.trackIndex); + } else { + // Else, use first track. + if (play) { + this.playerService.setTrackAndPlay(result.queue[0]); + } else { + this.playerService.setTrackAndBackgroundImage(result.queue[0]); + } + + this.setTrackIndex(0); + } + }); + } + + private canMoveToTrack(index: number) { + return index < this.tracks.length; + } + + private playTrackBasedOnRepeatValue(): boolean { + switch (this.repeat()) { + case Repeat.None: + return false; + case Repeat.Once: + if (this.hasRepeatedCurrentTrack) { + // Move to the next track. + this.moveToNextTrack(); + } else { + this.playerService.setTrackAndPlay(this.activeTrack); + this.hasRepeatedCurrentTrack = true; + } + return true; + case Repeat.Loop: + this.playerService.setTrackAndPlay(this.activeTrack); + return true; + } + } + + public getUpcomingTrack(): Track | undefined { + let track = this.tracks.at(this.index + 1); + if (track != undefined) { + return track; + } + + // If there isn't a next track, try the first track (expecting loop around) + return this.tracks.at(0); + } +} diff --git a/osse-web/src/app/shared/services/track/track.ts b/osse-web/src/app/shared/services/track/track.ts new file mode 100644 index 0000000..201601e --- /dev/null +++ b/osse-web/src/app/shared/services/track/track.ts @@ -0,0 +1,171 @@ +import { WritableSignal, computed, signal } from "@angular/core"; +import { LocatorService } from "../../../locator.service"; +import { getNicelyFormattedTime } from "../../util/time"; +import { ApiService } from "../api.service"; +import { ArtistStoreService } from "../artist/artist-store.service"; +import { OsseTrack } from "./osse-track"; +import { Artist } from "../artist/artist"; +import { v4 as uuid } from 'uuid'; +import { ConfigService } from "../config/config.service"; + +export class Track { + public track!: OsseTrack; + private artistStore: ArtistStoreService = LocatorService.injector.get(ArtistStoreService); + private apiService: ApiService = LocatorService.injector.get(ApiService); + private configService: ConfigService = LocatorService.injector.get(ConfigService); + public bufferSize: number = 0; + public artists: WritableSignal = signal([]); + public artistPrimary = computed(() => { + let artists = this.artists(); + return artists.at(0); + }); + public artistNames = computed(() => { + let names = this.artists().map((a) => a.name); + if (names.length == 0) { + return '(None)'; + } + + if (names.length == 1) { + return names[0]; + } + + if (names.length == 2) { + return names.join(' and ') + } + + let allButLastName = names.slice(0, -1).join(', '); + let lastName = names[names.length - 1]; + return `${allButLastName}, and ${lastName}`; + }); + /** + * Generates a random uuid. Use for a unique identifier instead of track ID. This changes each time this class is created. + * Track IDs should be used for server communication only. + */ + public uuid: string; + + constructor(track: OsseTrack) { + this.track = track; + this.uuid = uuid(); + + // Grab the artist info + this.getArtist(); + } + + public get id() { + return this.track.id; + } + + public get title() { + return this.track.title; + } + + public get size() { + return this.track.size; + } + + public get duration() { + return this.track.duration; + } + + public get durationFormatted() { + return getNicelyFormattedTime(this.track.duration); + } + + public hasArtist(): boolean { + return this.track.artists != null && this.track.artists.length > 0; + } + + public async getArtist() { + if (!this.hasArtist()) { + return; + } + + // If we have the data in the request, use it. + if (this.track.artists != null) { + this.artists.set(this.track.artists.map((a) => new Artist(a))); + return; + } + + // Check if it already exists in the store. + for (let artistId of this.track.artist_ids ?? []) { + if (this.artistStore.artistIsLoaded(artistId)) { + this.addArtistById(artistId); + return; + } + } + + // We need to fetch artist. Check the fetch list to make sure we don't make multiple reqeuests for the same artist. + for (let artistId of this.track.artist_ids ?? []) { + if (this.artistStore.isFetchingArtist(artistId)) { + await new Promise((resolve, _reject) => { + let sub = this.artistStore.artistFetched.subscribe((_v) => { + sub.unsubscribe(); + resolve(); + }); + }); + + this.addArtistById(artistId); + return; + } + } + + // Not fetching artist, start fetching artist + for (let artistId of this.track.artist_ids ?? []) { + this.artistStore.addFetchingArtist(artistId); + let artist = await this.apiService.getArtist(artistId); + if (artist) { + this.artistStore.setArtist(artist); + this.addArtistById(artistId); + } + + this.artistStore.removeFetchingArtist(artistId); + this.artistStore.artistFetched.emit(artistId); + } + } + + private addArtistById(artistId: number) { + let artist = this.artistStore.getArtistById(artistId) ?? null; + this.artists.update((a) => { + a.push(artist as Artist); + return a; + }); + } + + /** + * Sometimes tracks are fetched from osse and made into classes, but the user can refer to the same instance. + * Later, these instances are in the same array. + * In cases like this, we need a fresh uuid and track instance. + * An example is the tracklist page. Each track is a Track class, so adding the same track to the queue will result in duplicate uuids! + */ + public regenerateTrack(): Track { + return new Track({ + id: this.track.id, + title: this.track.title, + size: this.track.size, + duration: this.track.duration, + track_number: this.trackNumber, + disc_number: this.discNumber, + bitrate: this.track.bitrate, + cover_art_id: this.track.cover_art_id, + scanned_at: this.track.scanned_at, + artist_ids: this.track.artists?.map((a) => a.id) ?? null, + artists: this.track.artists + }); + } + + public get trackNumber() { + return this.track.track_number; + } + + public get discNumber() { + return this.track.disc_number; + } + + public get scannedAt() { + return this.track.scanned_at; + } + + get coverURL() { + return this.configService.get('apiURL') + "api/cover-art/" + this.track.cover_art_id; + } +} diff --git a/osse-web/src/app/shared/ui/background-image.service.ts b/osse-web/src/app/shared/ui/background-image.service.ts new file mode 100644 index 0000000..770ebcf --- /dev/null +++ b/osse-web/src/app/shared/ui/background-image.service.ts @@ -0,0 +1,20 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { ConfigService } from '../services/config/config.service'; + +@Injectable({ + providedIn: 'root' +}) +export class BackgroundImageService { + public bgChanged = new EventEmitter(); + constructor(private configService: ConfigService) { } + + public setBG(bg: string) { + if (this.configService.get('showCoverBackgrounds')) { + this.bgChanged.emit(bg); + } + } + + public clearBG() { + this.bgChanged.emit('#'); + } +} diff --git a/osse-web/src/app/shared/ui/header/header.component.html b/osse-web/src/app/shared/ui/header/header.component.html new file mode 100644 index 0000000..a9f54bd --- /dev/null +++ b/osse-web/src/app/shared/ui/header/header.component.html @@ -0,0 +1,13 @@ +@if (type == 1) { +

{{text}}

+} @else if (type == 2) { +

{{text}}

+} @else if (type == 3) { +

{{text}}

+} @else if (type == 4) { +

{{text}}

+} @else if (type == 5) { +
{{text}}
+} @else if (type == 6) { +
{{text}}
+} diff --git a/osse-web/src/app/shared/ui/header/header.component.ts b/osse-web/src/app/shared/ui/header/header.component.ts new file mode 100644 index 0000000..495704e --- /dev/null +++ b/osse-web/src/app/shared/ui/header/header.component.ts @@ -0,0 +1,14 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-header', + imports: [], + templateUrl: './header.component.html', + styles: `` +}) +export class HeaderComponent { + @Input() + public type: number = 1; + @Input() + public text: string = ''; +} diff --git a/osse-web/src/app/shared/ui/icon/icon.component.html b/osse-web/src/app/shared/ui/icon/icon.component.html new file mode 100644 index 0000000..be844a4 --- /dev/null +++ b/osse-web/src/app/shared/ui/icon/icon.component.html @@ -0,0 +1,10 @@ + + + + + diff --git a/osse-web/src/app/shared/ui/icon/icon.component.ts b/osse-web/src/app/shared/ui/icon/icon.component.ts new file mode 100644 index 0000000..aa8bf66 --- /dev/null +++ b/osse-web/src/app/shared/ui/icon/icon.component.ts @@ -0,0 +1,14 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-icon', + imports: [], + templateUrl: './icon.component.html', + styles: `` +}) +export class IconComponent { + @Input('icon') data: string = ''; + @Input('class') cssClass: string = ''; + @Input('align') align: string = 'text-bottom'; + @Input('active') active: boolean = false; +} diff --git a/osse-web/src/app/shared/ui/loading/loading.component.css b/osse-web/src/app/shared/ui/loading/loading.component.css new file mode 100644 index 0000000..7e16bb4 --- /dev/null +++ b/osse-web/src/app/shared/ui/loading/loading.component.css @@ -0,0 +1,67 @@ +@media (width >=640px) { + + #loading-bar, + #background-loading-bar { + width: calc(100% - 8rem); + } +} + +@media (max-width: 640px) { + + #loading-bar, + #background-loading-bar { + width: 100%; + } +} + + +/* Top loading bar */ +.loading-bar { + animation: loading-animation 2s ease-out forwards; + animation-play-state: running; + transform-origin: left; + will-change: transform; +} + +#loading-bar:not(.loading-bar) { + transform: scaleX(0); +} + +/* Bottom loading bar */ + +.background-loading-bar { + animation: loading-fade-in 1s; + animation-play-state: running; + will-change: opacity; +} + +#background-loading-bar:not(.background-loading-bar) { + opacity: 0; +} + +/* This is for the fade out used by both bars. */ +.opacity-transition { + transition: opacity 500ms linear; +} + +/* Main bar loading animtion */ +@keyframes loading-animation { + 0% { + transform: scaleX(0); + } + + 100% { + transform: scaleX(1); + } +} + +/* Background fade in animation */ +@keyframes loading-fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/osse-web/src/app/shared/ui/loading/loading.component.html b/osse-web/src/app/shared/ui/loading/loading.component.html new file mode 100644 index 0000000..bb1e5c8 --- /dev/null +++ b/osse-web/src/app/shared/ui/loading/loading.component.html @@ -0,0 +1,9 @@ + diff --git a/osse-web/src/app/shared/ui/loading/loading.component.ts b/osse-web/src/app/shared/ui/loading/loading.component.ts new file mode 100644 index 0000000..d7b0503 --- /dev/null +++ b/osse-web/src/app/shared/ui/loading/loading.component.ts @@ -0,0 +1,59 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { LoadingService } from './loading.service'; + +@Component({ + selector: 'app-loading', + imports: [], + templateUrl: './loading.component.html', + styleUrl: `./loading.component.css`, +}) +export class LoadingComponent implements OnInit { + // Front loading bar + isLoading = signal(false); + opacity = signal(1); + componentReady = signal(false); + + // Back loading bar. + isFastEndToLoading = signal(false); + + constructor(private loadingService: LoadingService) { } + + /** + * Resets animations. + * Automatically called by the bottom bar if fastEndToLoading is set. + * Otherwise called at end of load. + */ + resetAnimations() { + this.opacity.set(0); + + setTimeout(() => { + this.isFastEndToLoading.set(false); + this.isLoading.set(false); + this.opacity.set(1); + }, 1000); + } + + ngOnInit(): void { + this.loadingService.loadingStarted.subscribe(() => { + // Show the loading bars. Prevents flicker. + // TODO: Find a better way than checking it each time. NgOnInit and NgAfterViewInit didn't work. + if (!this.componentReady()) { + this.componentReady.set(true); + } + + // Start loading animation. + this.isLoading.set(true); + }); + + this.loadingService.loadingFinished.subscribe((useFastEndAnimation) => { + if (useFastEndAnimation) { + // Show the bottom bar to appear to end the animation faster. + // The bottom bar will call the reset when its done. + this.isFastEndToLoading.set(true); + } else { + // End the enimation. + this.resetAnimations(); + } + }); + } +} diff --git a/osse-web/src/app/shared/ui/loading/loading.service.ts b/osse-web/src/app/shared/ui/loading/loading.service.ts new file mode 100644 index 0000000..0626f54 --- /dev/null +++ b/osse-web/src/app/shared/ui/loading/loading.service.ts @@ -0,0 +1,31 @@ +import { EventEmitter, Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class LoadingService { + + constructor() { } + public loadingStarted = new EventEmitter(); + /** + * Emits when loading is done. + * If the loading animation hasn't finished, we emit true to tell the background bar to appear (appear to end animation faster). + */ + public loadingFinished = new EventEmitter(); + + private loadingStartedAt: number = 0; + + startLoading() { + this.loadingStarted.emit(); + this.loadingStartedAt = Date.now(); + } + + /** + * Ends loading. + * If the time was less than 1 second, use early end to load. + */ + endLoading(): void { + let now = Date.now(); + this.loadingFinished.emit(now - this.loadingStartedAt < 1000); + } +} diff --git a/osse-web/src/app/shared/ui/modal/modal.component.html b/osse-web/src/app/shared/ui/modal/modal.component.html new file mode 100644 index 0000000..c9eba9a --- /dev/null +++ b/osse-web/src/app/shared/ui/modal/modal.component.html @@ -0,0 +1,8 @@ + +
+

{{title}}

+
+
+ +
+
diff --git a/osse-web/src/app/shared/ui/modal/modal.component.ts b/osse-web/src/app/shared/ui/modal/modal.component.ts new file mode 100644 index 0000000..3979fc8 --- /dev/null +++ b/osse-web/src/app/shared/ui/modal/modal.component.ts @@ -0,0 +1,57 @@ +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, ElementRef, ViewChild, ViewContainerRef, inject } from '@angular/core'; +import { ModalService } from './modal.service'; + +@Component({ + selector: 'app-modal', + imports: [], + templateUrl: './modal.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrl: './modal.styles.css' +}) +export class ModalComponent implements AfterViewInit { + // Parent Modal + @ViewChild('modal') modal!: ElementRef; + // VCR (dynamic content) + @ViewChild('vcr', { static: true, read: ViewContainerRef }) vcr!: ViewContainerRef; + title: string = ''; + // Active component + component!: ComponentRef; + cdr = inject(ChangeDetectorRef); + + constructor(private modalService: ModalService) { } + + public open() { + this.modal.nativeElement.showModal(); + } + + public close() { + this.modal.nativeElement.close(); + this.component.destroy(); + this.title = ''; + } + + ngAfterViewInit(): void { + this.modalService.onLoadComponent.subscribe((v) => { + // Clear the old component and save the new one + this.vcr.clear(); + // Load the component + this.component = this.vcr.createComponent(v[0] as any); + this.title = v[1]; + + // Set any input props + (v[2] ?? []).forEach((input: { name: string, val: any }) => { + this.component.instance[`${input.name}`] = input.val; + }); + + // Show changes + this.cdr.detectChanges(); + + // Listen for close events + this.component.instance['onClose'].subscribe((_: any) => this.close()); + }); + + this.modalService.onShow.subscribe(() => this.open()); + this.modalService.onClose.subscribe(() => this.close()); + } +} + diff --git a/osse-web/src/app/shared/ui/modal/modal.service.ts b/osse-web/src/app/shared/ui/modal/modal.service.ts new file mode 100644 index 0000000..2b3839a --- /dev/null +++ b/osse-web/src/app/shared/ui/modal/modal.service.ts @@ -0,0 +1,28 @@ +import { EventEmitter, Injectable, Output } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class ModalService { + @Output() onLoadComponent = new EventEmitter(); + @Output() onShow = new EventEmitter(); + @Output() onClose = new EventEmitter(); + + constructor() { } + + public setDynamicModal(component: any, input: {name: string, val: any}[], title: string) { + this.onLoadComponent.emit([component, title, input]); + } + + public setStaticModal(component: any, title: string) { + this.onLoadComponent.emit([component, title]); + } + + public show() { + this.onShow.emit(); + } + + public close() { + this.onClose.emit(); + } +} diff --git a/osse-web/src/app/shared/ui/modal/modal.styles.css b/osse-web/src/app/shared/ui/modal/modal.styles.css new file mode 100644 index 0000000..3d5aa92 --- /dev/null +++ b/osse-web/src/app/shared/ui/modal/modal.styles.css @@ -0,0 +1,4 @@ +dialog { + min-width: 15rem; + margin: auto; +} diff --git a/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.html b/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.html new file mode 100644 index 0000000..0b8a095 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.html @@ -0,0 +1,16 @@ +
+

Select a playlist to add {{tracks.length}} track(s) to.

+
+ + +
+ +
+ + +
+
diff --git a/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.ts b/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.ts new file mode 100644 index 0000000..09f39ee --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, WritableSignal, signal } from '@angular/core'; +import { Track } from '../../../services/track/track'; +import { PlaylistService } from '../../../services/playlist/playlist.service'; +import { Playlist } from '../../../services/playlist/Playlist'; +import { FormsModule } from '@angular/forms'; +import { ToastService } from '../../../../toast-container/toast.service'; + +@Component({ + selector: 'app-add-multiple-tracks-to-playlist', + imports: [FormsModule], + templateUrl: './add-multiple-tracks-to-playlist.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AddMultipleTracksToPlaylistComponent implements OnInit { + @Input('tracks') tracks: Track[] = []; + @Output('onClose') onClose = new EventEmitter(); + + playlists: WritableSignal = signal([]); + model: WritableSignal = signal(-1); + + constructor(private playlistService: PlaylistService, private notificationService: ToastService) { } + + public close() { + this.onClose.emit(); + } + + public async onSave() { + await this.playlistService.addTracksToPlaylist(Number(this.model()), this.tracks.map((t) => t.id)); + this.notificationService.info(this.tracks.length + ' tracks added to playlist.'); + this.close(); + } + + async ngOnInit(): Promise { + this.playlists.set(await this.playlistService.getAll()); + } +} + diff --git a/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.html b/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.html new file mode 100644 index 0000000..35ffa50 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.html @@ -0,0 +1,11 @@ +
+ @if (loadingPlaylists()) { +

Fetching playlist information...

+ } @else { + @if (playlists().length == 0) { + + } @else { + + } + } +
diff --git a/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.ts b/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.ts new file mode 100644 index 0000000..06a4d30 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.ts @@ -0,0 +1,32 @@ +import { Component, EventEmitter, Input, OnInit, Output, signal } from '@angular/core'; +import { Track } from '../../../services/track/track'; +import { PlaylistService } from '../../../services/playlist/playlist.service'; +import { Playlist } from '../../../services/playlist/Playlist'; +import { AddMultipleTracksToPlaylistComponent } from "../add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component"; +import { CreateNewPlaylistForTracksComponent } from "../create-new-playlist-for-tracks/create-new-playlist-for-tracks.component"; + +@Component({ + selector: 'app-add-to-playlist-factory', + imports: [AddMultipleTracksToPlaylistComponent, CreateNewPlaylistForTracksComponent], + templateUrl: './add-to-playlist-factory.component.html', + styles: `` +}) +export class AddToPlaylistFactoryComponent implements OnInit { + @Input('tracks') tracks: Track[] = []; + @Output('onClose') onClose = new EventEmitter(); + + loadingPlaylists = signal(true); + playlists = signal([]); + + constructor(private playlistService: PlaylistService) { } + + public close() { + this.onClose.emit(); + } + + async ngOnInit(): Promise { + this.loadingPlaylists.set(true); + this.playlists.set(await this.playlistService.getAll()); + this.loadingPlaylists.set(false); + } +} diff --git a/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.html b/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.html new file mode 100644 index 0000000..edbdfd6 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.html @@ -0,0 +1,7 @@ +
+ Album art + +
+ +
+
diff --git a/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.ts b/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.ts new file mode 100644 index 0000000..f756e55 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, signal, WritableSignal } from '@angular/core'; + +@Component({ + selector: 'app-album-art-fullscreen', + imports: [], + templateUrl: './album-art-fullscreen.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AlbumArtFullscreenComponent implements OnInit { + @Input('url') url: string = ''; + @Output('onClose') onClose = new EventEmitter(); + public albumUrl: WritableSignal = signal(''); + + public close() { + this.onClose.emit(); + } + + public ngOnInit(): void { + this.albumUrl.set(this.url); + } +} diff --git a/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.html b/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.html new file mode 100644 index 0000000..cde1a74 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.html @@ -0,0 +1,14 @@ +
+

Create a playlist to add the track(s) to.

+ +
+ + +
+ +
+ + +
+
diff --git a/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.ts b/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.ts new file mode 100644 index 0000000..7d1041d --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.ts @@ -0,0 +1,52 @@ +import { Component, EventEmitter, Input, Output, signal, WritableSignal } from '@angular/core'; +import { Track } from '../../../services/track/track'; +import { Playlist } from '../../../services/playlist/Playlist'; +import { PlaylistService } from '../../../services/playlist/playlist.service'; + +import { FormsModule } from '@angular/forms'; +import { OssePlaylist } from '../../../services/playlist/osse-playlist'; +import { ToastService } from '../../../../toast-container/toast.service'; + +@Component({ + selector: 'app-create-new-playlist-for-tracks', + imports: [FormsModule], + templateUrl: './create-new-playlist-for-tracks.component.html', + styles: `` +}) +export class CreateNewPlaylistForTracksComponent { + @Input('tracks') tracks!: Track[]; + @Output('onClose') onClose = new EventEmitter(); + playlists: WritableSignal = signal([]); + playlistModel: WritableSignal = signal(""); + + constructor(private playlistService: PlaylistService, private notificationService: ToastService) { } + + public close() { + this.onClose.emit(); + } + + public async onSave() { + let playlistId = 0; + try { + let req = await this.playlistService.createPlaylist(this.playlistModel()); + let res: OssePlaylist = await req.json(); + playlistId = res.id; + } catch (error) { + this.notificationService.error('Failed to create playlist. Check that the name is not already in use.') + return; + } + + try { + await this.playlistService.addTracksToPlaylist(playlistId, this.tracks.map((t) => t.id)); + this.notificationService.info(`Playlist created. ${this.tracks.length} tracks added to playlist.`); + } catch (error) { + this.notificationService.error('The playlist was created, but the tracks were not added. Try again?') + } + + this.close(); + } + + async ngOnInit(): Promise { + this.playlists.set(await this.playlistService.getAll()); + } +} diff --git a/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.html b/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.html new file mode 100644 index 0000000..2acba9a --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.html @@ -0,0 +1,20 @@ +
+
+ + + +
+ +
+ + +
+
diff --git a/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.ts b/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.ts new file mode 100644 index 0000000..ebeb6f9 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.ts @@ -0,0 +1,39 @@ +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, signal, ViewChild, WritableSignal } from '@angular/core'; +import { ConfigService } from '../../../services/config/config.service'; +import { ToastService } from '../../../../toast-container/toast.service'; + +@Component({ + selector: 'app-player-settings', + imports: [], + templateUrl: './player-settings.component.html', + styles: `` +}) +export class PlayerSettingsComponent implements OnInit { + @Input() visualizerSignal!: WritableSignal; + @Output() onClose = new EventEmitter(); + @ViewChild('samples') sampleElement!: ElementRef; + public showVisualizer: WritableSignal = signal(false); + public visualizerSamples: WritableSignal = signal(1); + + constructor(private configService: ConfigService, private notificationService: ToastService) { } + + public save() { + this.configService.save('showVisualizer', this.showVisualizer()); + this.configService.save('visualizerSamples', Number(this.sampleElement.nativeElement.value)); + this.visualizerSamples.set(Number(this.sampleElement.nativeElement.value)); + // This will cause a visualizer change. The samples are pulled from the config service directly. + this.visualizerSignal.set(this.showVisualizer()); + this.notificationService.info('Saved Preferences!'); + + this.close(); + } + + public close() { + this.onClose.emit(); + } + + ngOnInit(): void { + this.showVisualizer.set(this.configService.get('showVisualizer')); + this.visualizerSamples.set(this.configService.get('visualizerSamples')); + } +} diff --git a/osse-web/src/app/shared/ui/modals/track-info/track-info.component.html b/osse-web/src/app/shared/ui/modals/track-info/track-info.component.html new file mode 100644 index 0000000..8b09ed4 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/track-info/track-info.component.html @@ -0,0 +1,20 @@ +
+
+ @if (trackInfo) { +

Title:

+

{{trackInfo.title}}

+

Duration:

+

{{trackInfo.durationFormatted}}

+

Artist:

+

{{trackInfo.artistNames()}}

+

Track Number:

+

{{trackInfo.trackNumber ?? 'None'}}

+

Disc Number:

+

{{trackInfo.discNumber ?? 'None'}}

+ } +
+ +
+ +
+
diff --git a/osse-web/src/app/shared/ui/modals/track-info/track-info.component.ts b/osse-web/src/app/shared/ui/modals/track-info/track-info.component.ts new file mode 100644 index 0000000..1e5ab4c --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/track-info/track-info.component.ts @@ -0,0 +1,18 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Track } from '../../../services/track/track'; + +@Component({ + selector: 'app-track-info', + imports: [], + templateUrl: './track-info.component.html', + styles: `` +}) +export class TrackInfoComponent { + @Input() + public trackInfo!: Track; + @Output() onClose = new EventEmitter(); + + public close() { + this.onClose.emit(); + } +} diff --git a/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.html b/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.html new file mode 100644 index 0000000..1a591e6 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.html @@ -0,0 +1,30 @@ +
+ @if (mode() == TrackMatrixMode.View) { + + } @else if (mode() == TrackMatrixMode.Select) { + + } +
diff --git a/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.ts b/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.ts new file mode 100644 index 0000000..03dc973 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.ts @@ -0,0 +1,45 @@ +import { Component, input, InputSignal, output, signal, WritableSignal } from '@angular/core'; +import { Track } from '../../../services/track/track'; +import { TrackMatrixMode } from '../track-matrix-mode.enum'; +import { CommonModule } from '@angular/common'; +import { TrackField, TrackInfo } from '../track-info'; + +@Component({ + selector: 'app-matrix-item', + imports: [CommonModule], + templateUrl: './matrix-item.component.html', + styles: `` +}) +export class MatrixItemComponent { + public track = input.required(); + public TrackMatrixMode = TrackMatrixMode; + public mode: InputSignal = input(TrackMatrixMode.View); + public selected: WritableSignal = signal(false); + public visibleTrackInfo = input(); + public trackInfo = TrackInfo; + public trackField = TrackField; + + public onClick = output(); + public onSelectToggle = output(); + + public toggleSelected() { + this.selected.set(!this.selected()); + this.onSelectToggle.emit(this.selected()); + } + + public determineClickTypeAndEmitEvent(event: MouseEvent) { + if (event.ctrlKey) { + this.toggleSelected(); + } else { + this.onClick.emit(); + } + } + + public emitOnClickEvent() { + this.onClick.emit(); + } + + public setSelected(selected: boolean) { + this.selected.set(selected); + } +} diff --git a/osse-web/src/app/shared/ui/track-matrix/track-info.ts b/osse-web/src/app/shared/ui/track-matrix/track-info.ts new file mode 100644 index 0000000..271e1e6 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-info.ts @@ -0,0 +1,22 @@ +/** + * Available fields to show in a track matrix UI. +*/ +export enum TrackField { + TrackNumber, + Title, + Artist, + Duration +} + +/** + * Track fields wrapper. +*/ +export class TrackInfo { + public static allFields() { + return [TrackField.TrackNumber, TrackField.Title, TrackField.Artist, TrackField.Duration]; + } + + public static default() { + return [TrackField.Title, TrackField.Artist, TrackField.Duration]; + } +} diff --git a/osse-web/src/app/shared/ui/track-matrix/track-matrix-click.enum.ts b/osse-web/src/app/shared/ui/track-matrix/track-matrix-click.enum.ts new file mode 100644 index 0000000..c4b396e --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-matrix-click.enum.ts @@ -0,0 +1,4 @@ +export enum TrackMatricClick { + Normal, + Ctrl +} diff --git a/osse-web/src/app/shared/ui/track-matrix/track-matrix-mode.enum.ts b/osse-web/src/app/shared/ui/track-matrix/track-matrix-mode.enum.ts new file mode 100644 index 0000000..9068311 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-matrix-mode.enum.ts @@ -0,0 +1,4 @@ +export enum TrackMatrixMode { + View, + Select, +} diff --git a/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.html b/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.html new file mode 100644 index 0000000..3706a92 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.html @@ -0,0 +1,9 @@ +
+ @for (track of tracks(); track track.uuid) { + + } @empty { +

No tracks found.

+ } +
diff --git a/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.ts b/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.ts new file mode 100644 index 0000000..af8845f --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.ts @@ -0,0 +1,65 @@ +import { Component, ViewChildren, WritableSignal, input, output, signal } from '@angular/core'; +import { Track } from '../../services/track/track'; +import { MatrixItemComponent } from './matrix-item/matrix-item.component'; +import { TrackMatrixMode } from './track-matrix-mode.enum'; +import { TrackField, TrackInfo } from './track-info'; + +@Component({ + selector: 'app-track-matrix', + imports: [MatrixItemComponent], + templateUrl: './track-matrix.component.html', + styles: `` +}) +export class TrackMatrixComponent { + @ViewChildren(MatrixItemComponent) items!: MatrixItemComponent[]; + public tracks = input([]); + + public selectedTracks: Track[] = []; + public mode: WritableSignal = signal(TrackMatrixMode.View); + public visibleTrackInfo: WritableSignal = signal(TrackInfo.default()); + + public onClick = output(); + public onModeChanged = output(); + public onEmptySelection = output(); public onTrackSelected = output(); + + public toggleSelect(selected: boolean, track: Track) { + if (selected) { + // If a CTRL click happened and the mode is view, switch to edit and select it. + if (this.mode() == TrackMatrixMode.View) { + this.setMode(TrackMatrixMode.Select); + } + + this.selectedTracks.push(track); + this.onTrackSelected.emit(track); + } else { + this.selectedTracks = this.selectedTracks.filter((t) => t.uuid != track.uuid); + } + + this.checkForEmptySelection(); + } + + public setMode(mode: TrackMatrixMode) { + this.mode.set(mode); + this.onModeChanged.emit(this.mode()); + } + + public clearSelectedTracks() { + this.selectedTracks = []; + this.items.forEach((i) => i.setSelected(false)); + this.onEmptySelection.emit(); + } + + private checkForEmptySelection() { + if (this.selectedTracks.length == 0) { + this.onEmptySelection.emit(); + } + } + + public getSelectedTracks() { + return this.selectedTracks; + } + + public setVisibleFields(fields: TrackField[]) { + this.visibleTrackInfo.set(fields); + } +} diff --git a/osse-web/src/app/shared/util/fetcher.ts b/osse-web/src/app/shared/util/fetcher.ts new file mode 100644 index 0000000..6ba9503 --- /dev/null +++ b/osse-web/src/app/shared/util/fetcher.ts @@ -0,0 +1,63 @@ +import { LocatorService } from "../../locator.service"; +import { ConfigService } from "../services/config/config.service"; + +export async function fetcher(url: string, args: Partial = { method: 'GET', headers: [], body: null, rootURL: null }): Promise { + let token = ''; + try { + token = await getCSRFToken() as string; + } catch (e) { + console.error('Failed to get CSRF Token. Check the the server is running and that the URL is correct'); + throw 'CSRF Error'; + } + + // Add the XSRF token to the header list + let headers = new Headers(args.headers); + headers.append('X-XSRF-TOKEN', decodeURIComponent(token)); + headers.append('Content-Type', 'application/json'); + headers.append('Accept', 'application/json'); + + return fetch((args.rootURL ?? LocatorService.injector.get(ConfigService).get('apiURL') + 'api/') + url, { + method: args.method, + headers: headers, + body: args.body, + credentials: 'include' + }); +} + +export async function getCSRFToken() { + let token = getCookie('XSRF-TOKEN'); + + if (!token) { + console.log('Fetching CSRF token.'); + let res = await fetch(LocatorService.injector.get(ConfigService).get('apiURL') + 'sanctum/csrf-cookie', { + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + }); + + if (res.ok) { + console.log('Fetched token!'); + return getCookie('XSRF-TOKEN'); + } + + throw 'CSRF Error.' + } + + return token; +} + +export function getCookie(name: string) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift(); + return ''; +} + +export type FetcherArgs = { + method: string, + headers: HeadersInit, + body: BodyInit | null, + rootURL: string | null +} diff --git a/osse-web/src/app/shared/util/time.ts b/osse-web/src/app/shared/util/time.ts new file mode 100644 index 0000000..c596704 --- /dev/null +++ b/osse-web/src/app/shared/util/time.ts @@ -0,0 +1,19 @@ +/** + * Returns time in a duration format. + * 0:04 +*/ +export function getNicelyFormattedTime(seconds: number): string { + if (seconds < 0) { + return '0:00'; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; + } else { + return `${minutes}:${String(secs).padStart(2, '0')}`; + } +} diff --git a/osse-web/src/app/toast-container/toast-container.component.css b/osse-web/src/app/toast-container/toast-container.component.css new file mode 100644 index 0000000..a53ad6d --- /dev/null +++ b/osse-web/src/app/toast-container/toast-container.component.css @@ -0,0 +1,14 @@ +@keyframes toast { + from { + width: 100%; + } + + to { + width: 0%; + } +} + +.toast-animation-bar { + animation-name: toast; + animation-timing-function: linear; +} diff --git a/osse-web/src/app/toast-container/toast-container.component.html b/osse-web/src/app/toast-container/toast-container.component.html new file mode 100644 index 0000000..008c589 --- /dev/null +++ b/osse-web/src/app/toast-container/toast-container.component.html @@ -0,0 +1,18 @@ +
+ @for (toast of toastService.toasts(); track toast.id) { + + + + } +
diff --git a/osse-web/src/app/toast-container/toast-container.component.ts b/osse-web/src/app/toast-container/toast-container.component.ts new file mode 100644 index 0000000..65143a6 --- /dev/null +++ b/osse-web/src/app/toast-container/toast-container.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { NotifyType, ToastService } from './toast.service'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-toast-container', + imports: [CommonModule], + templateUrl: './toast-container.component.html', + styleUrl: `./toast-container.component.css` +}) +export class ToastContainerComponent { + constructor(public toastService: ToastService) { } + public NotifyType = NotifyType; + + public removeToast(id: string) { + this.toastService.removeToast(id); + } +} diff --git a/osse-web/src/app/toast-container/toast.service.ts b/osse-web/src/app/toast-container/toast.service.ts new file mode 100644 index 0000000..996a44e --- /dev/null +++ b/osse-web/src/app/toast-container/toast.service.ts @@ -0,0 +1,51 @@ +import { effect, Injectable, signal } from '@angular/core'; +import { v4 as uuid } from "uuid"; + +@Injectable({ + providedIn: 'root' +}) +export class ToastService { + public toasts = signal([]); + constructor() { + // When a toast is closed, remove it after 400ms (this gives time for the opacity animation) + effect(() => { + this.toasts(); + setTimeout(() => this.toasts.update((arr) => arr.filter((t) => !t.closed)), 400); + }) + } + + public info(message: string) { + let toast = new ToastMessage(message, NotifyType.Info, uuid(), 6) + this.toasts.update((t) => [...t, toast]); + setTimeout(() => this.removeToast(toast.id), 6000); + } + + public error(message: string) { + let toast = new ToastMessage(message, NotifyType.Error, uuid(), 7); + this.toasts.update((t) => [...t, toast]); + setTimeout(() => this.removeToast(toast.id), 7000); + console.error(toast); + } + + public removeToast(id: string) { + this.toasts.update((arr) => arr.map((t) => { + if ((t.id) == id) { + t.closed = true; + } + + return t; + })); + } +} + +export class ToastMessage { + public closed: boolean = false; + + constructor(public message: string, public type: NotifyType, public id: string, public duration: number = 6) { } +} + +export enum NotifyType { + Info, + Error +} + diff --git a/osse-web/src/app/track-list/track-list.component.html b/osse-web/src/app/track-list/track-list.component.html new file mode 100644 index 0000000..ade4851 --- /dev/null +++ b/osse-web/src/app/track-list/track-list.component.html @@ -0,0 +1,38 @@ + +
+
+
+
+ +
+
+
+

Start typing to view results. Press enter to add all visible tracks. Click on a track to play + it, or right click (hold on mobile) to select multiple tracks. +

+ +
+
+
+ + + +
+ +
+ +
+
+
+
diff --git a/osse-web/src/app/track-list/track-list.component.ts b/osse-web/src/app/track-list/track-list.component.ts new file mode 100644 index 0000000..0360fa0 --- /dev/null +++ b/osse-web/src/app/track-list/track-list.component.ts @@ -0,0 +1,174 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, OnDestroy, OnInit, ViewChild, WritableSignal, signal } from '@angular/core'; +import { TrackService } from '../shared/services/track/track.service'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { Track } from '../shared/services/track/track'; +import { ToastService } from '../toast-container/toast.service'; +import { fetcher } from '../shared/util/fetcher'; +import { debounceTime, fromEvent, Subscription } from 'rxjs'; +import { TrackMatrixComponent } from '../shared/ui/track-matrix/track-matrix.component'; +import { TrackMatrixMode } from '../shared/ui/track-matrix/track-matrix-mode.enum'; import { IconComponent } from "../shared/ui/icon/icon.component"; +import { mdiClose, mdiPencil, mdiPlaylistPlay } from '@mdi/js'; +import { CommonModule } from '@angular/common'; +import { ModalService } from '../shared/ui/modal/modal.service'; +import { AddToPlaylistFactoryComponent } from '../shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component'; + +@Component({ + selector: 'app-track-list', + templateUrl: './track-list.component.html', + styles: ``, + imports: [HeaderComponent, TrackMatrixComponent, IconComponent, CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TrackListComponent implements AfterViewInit, OnInit, OnDestroy { + @ViewChild('search') searchBar!: ElementRef; + @ViewChild(TrackMatrixComponent) matrix!: TrackMatrixComponent; + + public loading: WritableSignal = signal(true); + public tracks: WritableSignal = signal([]); + public editing: WritableSignal = signal(false); + private allTracks: Track[] = []; + private timeout: number = 0; + private scrollSubscription!: Subscription; + + pencil = mdiPencil; + close = mdiClose; + play = mdiPlaylistPlay; + + constructor( + private trackService: TrackService, + private notificationService: ToastService, + private modalService: ModalService + ) { } + + ngAfterViewInit(): void { + this.searchBar.nativeElement.focus(); + } + + async ngOnInit(): Promise { + // Listen for scroll events + this.scrollSubscription = fromEvent(window, 'scroll') + .pipe(debounceTime(300)) + .subscribe(() => { + const endOfPage = window.innerHeight + window.pageYOffset >= (document.body.offsetHeight * 0.6); + if (endOfPage) { + this.requestTracks(this.searchBar.nativeElement.value); + } + }) + + // On load, get the first 75 tracks + let req = await fetcher('tracks/search'); + this.loading.set(false); + if (!req.ok) return; + + let tracks = await req.json(); + tracks.forEach((track: any) => { + this.allTracks.push(new Track(track)); + }); + this.tracks.set(this.allTracks); + } + + public onSubmit() { + for (let track of this.tracks()) { + this.trackService.addTrack(track); + } + this.notificationService.info('Added ' + this.tracks().length + ' tracks'); + } + + public addTrack(track: Track) { + this.trackService.addTrack(track); + this.notificationService.info('Added ' + track.title); + } + + public playSelectedTracks() { + let tracks = this.matrix.getSelectedTracks(); + for (const track of tracks) { + this.trackService.addTrack(track); + } + + this.notificationService.info('Added ' + tracks.length + ' tracks.'); + } + + public addSelectedTracksToPlaylist() { + this.modalService.setDynamicModal(AddToPlaylistFactoryComponent, [{ + name: 'tracks', + val: this.matrix.getSelectedTracks() + }], 'Add to Playlist'); + this.modalService.show(); + } + + public async onInput(ev: any) { + // Search for tracks. We made a debounce which waits 500ms before sending. + // Makes it a little easier on the server. + + // If the search input is empty, reset the filter + if (ev.target.value.length == 0) { + this.tracks.set(this.allTracks); + } + + clearTimeout(this.timeout); + this.timeout = setTimeout(async () => { + // Don't search for empty string + if (ev.target.value.trim() == '') return; + this.requestTracks(ev.target.value.trim()); + }, 500); + } + + public async requestTracks(search: string) { + // Find the amount of track that we have that match the regex. + let offset = 0; + + if (search.length == 0) { + offset = this.tracks().length; + } else { + const regex = new RegExp('%' + search + "%"); + this.tracks().forEach(val => { + if (regex.test(val.title)) { + offset += 1; + } + }); + if (offset < 75) { + offset = 0; + } + } + + // Search for tracks + this.loading.set(true); + let req = await fetcher('tracks/search?' + + new URLSearchParams({ + track: search, + track_offset: offset.toString() + }).toString()); + this.loading.set(false); + if (!req.ok && req.status == 200) return; + + let json = await req.json(); + for (let track of json) { + if (this.allTracks.some(v => v.id == track.id)) continue; + this.allTracks.push(new Track(track)); + } + this.tracks.set(this.getMatchingTracks(search)); + } + + public getMatchingTracks(search: string): Track[] { + let regex = new RegExp(search, 'i'); + return this.allTracks.filter((v) => regex.test(v.track.title)); + } + + public handleModeChange(mode: TrackMatrixMode) { + if (mode == TrackMatrixMode.Select) { + this.editing.set(true); + } else { + this.editing.set(false); + } + } + + public handleEmptySelection() { + this.matrix.setMode(TrackMatrixMode.View); + } + + ngOnDestroy(): void { + if (this.scrollSubscription) { + this.scrollSubscription.unsubscribe(); + } + } +} diff --git a/osse-web/src/assets/.gitkeep b/osse-web/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/osse-web/src/assets/icons/android-chrome-192x192.png b/osse-web/src/assets/icons/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..3cd1149f5e2c5b53d8350483f26934c8d99ca6b5 GIT binary patch literal 16760 zcmV*BKyJT@P)DN!% zrtACpVUyQS?!9$y$*EKEidAg#m-tZP4 zYFk|rBupUj;>DA{{`yPqVPPHHc|_Jt{$2L3+bj3)xl^M@pG3`%=k5hO`1=Hj5=gF+ zc~x1{=g5e66*+hGtn6O4T~6%%(}O#OF7?(3_@4rBMMj=U@#4pqO6{si-M)<_X^Nze zj0qLt+J&pKZ0G{nHh;5EciL1L(@L$Lb)`s+5|ScqN{JUgLba%v7f+m*1wYJ^{pY<#YvTa{{9PFlUzQSE8k(kn+Uk@Zu4m$cc^OSMk5rFflE_`5`Uukh=) zWW~ruvSI2PxqJJrq5Io$QHI=^r13K?b@9i?iN#at#+4g#blX8mlr*vAuTT(*nn2%w z*^mXYcG60pD5)La8jSbwYX#{1Q7s(tB zV1ZF?T)r-IzniW$_h0v&_wCbV&mb*cZYO!m70|^`xpexXEc$tl9NN4er&KcO_+EEC z!T2@@H;2iz|BjdI7q11;TH=Wt@$LQ~o?0n>s{qN~YrVSrszM3rC=4}csz6VBt8%4IlIdpt@ z(%*tPYxkVRG~j-#01PMg8Tcf2dDzu3XU4xfT=uR0BiipsnIRRN&<+}Yq`7qe{(af8 zXp7AKcADJ2dE3^-7C&Kpsnw%CcJ+FZv3`*&=dQ?%&nL*CP5U1)z38uWha;aMS0+`? z^;1{N{O@Pj`c8pY}KIkxMtj^(+dXW-j~JNkJ(Q2-_E=LCG_Gv$uOc_)OxbSCvJy~`w*=CzR!21@hSwijpxL$hi-aQ%j#xOasabK)IWhAEczaJBx2$bWykIGc|0~e2P1?QP zMZ>UE21cG1|29uHPG1w_B*ce_)aum^Ql?1-y+8eg@49~G_E~Y^0-CQ)o@*_o8kK{& zdI=G{)oLncgEHhPl}}CD`#Al>Z-nc(ojG_?&i{E{g9_Z}IIkOLtbvjoFg;v;s{px5 z=8-<%Jsp+#9rxBy30oU`B8i#UI(-^R)yHb7Q%sw3_1NXIbjW6!JbP)2EV@i2BhSrZu350L%eR%2WGKXkfuCB*t$Q zAV;xW()askqLN*c-X9&KnepKK@JQ`|&|{YT*>o@&lOOZ%!Iq#1NfGgB`mfefx>0$3 z6bJrf*l`*6<}l4fyOZi&>Po{Wo9SiYH^XJ{Q{O8@Ld8d>hr4%n^zUKo=PaH_x_sP6 zcnT2Cvh=t4y0|mqbYc$gBV-VtW47i(&*_H3ca9Q#c!v5JYJnyqf)3^QR(_|->4mH z!hc3e*qZ1gT1M{~sMj%s+~m0yQl@c5&5KO>V2u2^>xhQC8$8)u%D1Sb^ELg`33Bk^ zs2Vo6+=vb~#C&jrJdaa!$>8}gjWL!c9bvy!fHYasX(B9D#?(63>!+=jc>|{#o_9LL zoj&NPaD?cD3;zyI1SX|U!@0T%Q|bDp>v9>HTspUOq;rS@$i3igt_}O z3dGHhkdeP)K=>wtZ;&!wD#d_NS*(cPDnPQ-$)(>9&nW(%vSaZUnflQ;q({$UIk63fGdzkJw2k8=yzi{_FietIrK0g$7ZiXdW-83dD>}ceU+*aN3-+$sE%T5h zj4Ob*-pKVFC|u94`b*xj1@uoCBAfEzSdZQ!Q@$+f5MMfVQTF`46G!S_k}P$yh&VDF zt~8m`3S;|>J=58x@4zEii*68q=B|GgaEf(PR%rz4-W_w5UFi|gcxKqEdT*Wpv~PFV zsQ`W+N#eLR7|={AwW+Em;0mC6w=LL&F}^QNo^2_`>XcTP_~iG;Sc=wpv^XL!_Fo0S z3l^+W1ZdAXI1X>a0bUG%+cD3-F_x2y3!Mv}G37)_5-Y-#$1vl}gjCa6z5(PW6~U~N z#TRBaxc$4`Bp-Z%3rOBfd~f96VNlP%D4JjR7b5fF7i8v^3%zvX3JM>&jZpBFGneJ+ z`75ZP%Y)0~$-R=)XyU2BgchSg7m@KTTC=3^`WpyJtQxZvr}r*P z8&GUx?biwr*A_`!_+Fm6Dbl9|_9vGxJj{ex>P)FMS{G4M@mIvZBNJWB1dn?Ym2^b* zIv@eA07hVS4vd;3xAbo_#)(hn36KK*7pv;%mk|NXOmC9pf7gFsg9$qR?O}5K4-@9E zftL<&1u*c1p-Tc{1gH$DVf(XoA|K2omz>=9C!p{n9jm1dDL_Ch4`*M}pMpsA6B?kv zDP6X73XfPZVzGp+GoR^oH*z5ba5q9h_xJv9HZm+tKjG%(8*=R;z=kGa4o0RfqyR=n zDRhnH5%5>Fi$MY6o&Qn-BF#eqpN=0c@ zf?5HRfKQa*tF&3uE7b-wjZUZ5jShV1I%^}qO0uRew3X5*HoAHN;5i)dsWYVkW^0Gc z{Bn|E&|=Uk0E^LU_X24autdb45QTXjJH*J>2b-=Q;yrVrMj&1G^h*7CYX5O9tnmg5 zool(+X2V-T_xjeHBt)icvr3?(j4*xXeLqv@n6=l;D3h6Q>+JP%*8nYj&?-PefZJNV z)?P~1FB?@qN~)9*FAp-!8pk3V7IP5XM!9(6f=v9sQA*hn67P)wO`pf!@2NZf*7=)c z&eu~Fz{tAm@~x`Kxg%##0y|#G%G^oNDgd!0CF+${YIV{W5^!}8QG)58PmHnB$lYMo zeY}a(>RvBOp$Kgr1IFir8)Bzxad+R)-y0tRR_e6^^-V!+Mif@?jePl6xd=u%{vA1( z$TGKgUTU$Vp;odZSO%}};Sq#|ujHochr%@l?9 zhQ#?1NB}d3JgbDr-vey=CdwhxXUm|4G_?C!)|$I?K56@ACs3)RMgh|bSvhJ+tkq>f ztpIjU32jf-Dl=0_4Y&d(0;NT9^#ZW_fuZ~92#^heW)A^#mD^QEmN=F2#GVI@Vq+A@ z)Ba!Z<1E=Scca9|$zKCx$<@2o)!lyvP62I95<@mQqyV8s=GP;@Vj6O>mTy@_eXsb~ zgh+nOqM|z>7h^3ktNvI|Fzu6Zu>a4hZ_w)B9YL>BP|e<1Urvq@P90JJzn=ck%VkE% zYF7|waZ$Va!|hqQ6ACa&2ZtwehyQd_yMOz_%}PAXUvUotaHAds!7Y}ByK8z_vSA2 zBs)jIjUUNk`lc_oQB#NtaUI1pv%i^!&O*CmgsTL#0?;-jD;r_6=fNV(4kNY-CWH)h zj4_kzCM3Qe0WtxS5sYcF|I69C%!X+*)I_? z!1bg$`Z%zi(hM}#I2PHiHnwN_SOJ_Ej);5G?5WbBrqt?LKPraodKwojcLq90WE~@z zjeV#*5p90=-#(HD*c0vk2>wvQaLINYl7~jXp&SqwKBGRQ&`gJJoQ2SmmK2v{@MhmO z(k!|YA1HuxyZ*x;OAJXykS@_KBr=puw;wzWxNzwgTtMTD%}AcGiH1>Io|KK`{Ac)$ z_xXMFQdtJNZxX(S#5*Iv*0Zf%Z4aDKZjBIeZrNUSDj*TpFI+S95L+@q#MN|eR zMyj-{A!)%m!?4Q`gwy zTiE{b-ngL;Ld#|0<26Bl;Ut(_m+V95ok^(CFtYpeH(pl&U&pRlO2;0=Y|fm&LLp@$ ze&kd@=aHj34{JdM+bW$sd`2B-o(@dOX9e!0e!nJ)KcSGgcQhLDo!oca@ulYL@pJqG zJ$OLrCV)EptBtVqmBN$Q)ekNT_v1z}y{-Tre4nG2@dfKm<1qF+^oNQiU9BV7?9 zmO3(o#!i!5`b&&e)?jXQGQCPjZS+2iENcR2t0-J&17onF4svL5^;w>DaQL7dsJVFOmbwkU^D$9ZsS%lfuyIXZ5({ za>g>>zi+=g;8y*^FC4j9nF&DnnF9F7RM4?af-on+SLw26R9in&-pm?n4hPnp{eelb za{9mto3hIx1+Z=AJq)=9oC0u1X6%>XCbBWukfq+tg zG+EOj?%PrON?ka94&koRYJ-O)ni24DCq$brk8Ee}jk&X@qX!D$YqT`B~mP)jd!E&pyWPt7(6;(3z1M$|dkW zjza7`CY-n3&$s1%L9$S78HgVzozeZ0U&p_% zpp*Kwb5~R`E(43i&gu8jH2gs(Iv-%rY#;XIfR&Kos%VW8dhhdy@DW` z0}$@3SUgmeK$KFA$|YpcO-9jv< zh3gqatsVdm<}M&p!`5O!_*xM>w9VMaq}~%vv=(E=7n5V8uxBM+y(gM#PKX-^asS)k zFDx1~$J96by#lbs5t+dl{-krX_?Nj#dmW6zg-)0@PX)3~5aG!Bh0OV~pt3Eqq=Fg6 zQ-kGyjF|l6G>9znXU+4UFdtYJcw#T=6Jja;cgCePH#6++!V=bXlUB+i%yCQsfNX_x zDi2Z7^00aJQV{9;M-v;T%)+OMneR0{1cBq0j$7;y~ar)K~$22su(1H zsjZq-hX0WK}_fbm~)*Oz= z*u!DstOc`4hxfWmhMbwAzLm`}#=JhnRDMMjz_&|0_N83BZYi}5S&4NU$NZRAf0d)V zj(TDJ``4(%WKmEVvKGoNMG!$L4o8wyfUceK-o1NpCXbC*sM4_(NC1ioKQkW&5G42 z4LX>5aHunCWQoGve>bwv3x1qwm@&ayW1Ir`pT6Hct#ix_KNoYQw$-&}n-L#ogLx8c znzc?9glnxg@4g7` zlvD5n812bXESD6mRZ>l(R2fohPcWKtbn)m&QjOpENQ-Het8lja< zgUD3&J;mz~sY&uFhA!Dp*NhEHoJec5Cgo1R#L&d|M+XcB$f*Tm^uAm~%sJEneIs{E z3L|NBG-rJ^S?9zbgcLK~5$fjpSTSO;(w{5L1-Zf`$&mLu4OvJ+(a5a+58`&!*j>Z$mI9OpsdsP zrzz{OJHFRV9r*iDf@7eh*#x_uonUSP2J#n_mZn`EphYX8Ngg33G?$2>;)%glsbw9?zraOPzkf5y8QE!tV}5umxbx9D<-Cs@ea8n%mP7=ELolq%Hnenj z33myD^SgiTUd2c8^dfOV)yHZ{4RC-ShJsA_h%9jE)M-*|R{#>v&iihyR6N_;apDsaE!*>eJx~o%YcL*{T7lM6FfN|Mvz;f?ex=k(~KdJ%5YXf5G=+ZiDpp1*;U* zX6O7f)_-rAvmROTEK;xVMHW530Wk>xauVuc#*U3ba3B-GCKwL;)6{mQw}C zRl2O{B_A3Y(}`t7h!9|gGwVM+1iN(^$+G!tQv5vO<*5g<_cW?02@op|6%6enS4KV`l-ALQ9c=4?=Pck z!WIRfo!0i9E|RrCc4Qb&%d}4?YRWoFV7L*>LtcIkHamFJ!>1TM0eFHm{a0&fe`QrH zIzHpx9&RZOZgh~maa~H(J@owI3AJ+{7KSXZq2u8};Qbf>I!~@4yKa{dV%_WO{!!>k zmR(N!?>Mb3hznv_0hp?!fhiR|Y=p;FE0?l4%r(Ii&Dgf`3 zA14W$pt-V|2hiGRJTifHn+Kjxyw$xp;)$${Edn4LGo!gn=2OrYfnpC5Mv)P3_SaMO zG>FbeFtcG7M0Hii|8xhsGLgOqJLRyqu9fhj=VR!bUszTE?pU2a>ZR?6TtXXi)N6ya zb~PsP#0hrYgHB@0SK8|dLgCK16iK&LW0qRL8#z7jCaTVGVkX97q}j-LIL#y05$iX@ zEMhEt%bX49OSak;W(+QTB5q68FRQs5nw7JWAY*k|u#05{NP#FqS2%BUN?gK_=a^T1 zlZ(jmno@vRwlSJpz0sN|d76j?LGax~6v;5F9E`%0e~w}~f#+%bcNP~)z*A2%xi}4& zO}8p}%I4S8)#YOy+j&&RzA@P2=7Cns0v+v901rlo zDem^~bk*W+*94N*W9o-vH9Trmcx1PKyQ?~i6ru*_4xB3N*X1vaYDT}slj~@s=PO?j zyLIb-)Wx0!BzV@BlhC-sLR=gRji(HOW+d=p?_@$G2xkvg@wx&ON93pFODJ(f4B91R z0{gk|*Pd08VX~M>uG71b(qmAWE}yw56W$pqCu4EGgJmKaDQbgL&~*~h^6U zRXf&<@CR^$Fn&Gne>3F5v2(#VU;p?fE>`wS;EBKl8c)!}z+TP4g1zwPc~jr-%C``5 zz|)5L2SOx{pu#U0#p?>di7Ej!D2r!Ebwa@0eAKC)_rM2ua3Tn^Af;YG(2vuRW2Q6= z@amagObj}#P;?8BVQBh8thu02378+5@!3Sh6q%8NRf^!Aog^jl5_Gh;FWihauoKMO z%I|+;SOLr=SYrEnmN=3Ljhrq~O^r9tM1vaLVk_mf_n zgjxK4ZUU^DV_<+>N}dXX4D{2e2)rNjI>P}$4;C_l2gk#(0(d%zUcDavK)&*YP(0sO z(&fky1@T=oaiy#tvn;@4*~YTWWYA_Zi%3Z>TINT%0kAH^jIdivJ17Ae=xC;{X1E$K zjF`nl!q3c$+5DT}Pyh@H+FAp2S5Vr#fulRJ0Gzx3O3w>8 z|E8L22}go+67F+g{XS1`<44y&dYT560&t|6C9MKJoZ7t`MB&^S$(ZxaG_4;9b|qj? zfRF}q>1YWlP_qQjF`VgXJElxvYU=@nDmi%wyjwkfxsvYMMSA8Bn&CACUqWsO*qCnW$K#c(E7*m}#?GiMNCT)d z7kwB`0Y-&9Ep%b#nSMawYQ=QOSbQ?^|3+z3j$jc-6o8@5Bp_vCNB3g2OG#cprI}dK zCJ>P=g3tpc0>Oz1x&vU>i9LTJhQCko{iMJkrpP>_Tm*?~a};}&1h**PhYNwV3alZp ziktEG$>;Yrc(R#P273Zcp>Ui6)1dr&f8XWNtJ9>c)~UAC1+f-46+%wvQykuWz@z*4 z^ToC*00*93+(~Db7f^-t!1}ndwC)DMbvzU0c>2HzHCOl#^8=n9-L7YmUxtaIq9BG+ z@EDYUxS1Ki9tC@=QubA7|4uh;yut5gf^aN2^gZ2FvKP&X+Tos2DA8^6HtF~Ui`b?B z2~e@c1_u>dSHaPnRvl7enC@fn+34*8cJ(X3v0pxQNq2X0z$8tTTsa^~s+&1qR^4S> z!rZ`xljmjg%=JozL1)&CkcWsZmhzO!r#~mkktDHm2Tr#IOK;|#yzv`uMlDb~QMPGC zeH?oP41Mk=k6{unGl-l{R3uiT{nYOpx5!(&^F~gMnMDPNi2X9`$Rn41uUwD>#-Ukc zy?faX#jTTgDJE`EE`07(q`o78R<06xl={v^*|BZzMlF1y&tOy@^!J${W5pAlI!zQo z2}l95_?LNxDH@GF*PpH<>XzM9T%g0rAB;wS;+-D9V#PMqP{G$Gs)Z>5a^!w8FLvb& zjO9pTdrylBfTDLewM|f~t5AfzD>PUfY+qf^IcW#T zIJ3G`a#G2dfi`mkY5Nvi7?smcVFlXASAGo&KgyB<(7x{dEea?QZXkeeRqNM?dwJs7IREGaKA`YSZASYS><-(WR` z>`+pgLF8qkS*7%p^r6t2G&)8O5B4}aUd_@y%qsv^J$d`t-k#3wFZ~BPh8D~KC${@% zk0TV9M$Hy-!ZTlBR_M2MfK%)$S@Xuo5if{jYpvyH-pBIh9>lVT5O zsA&bDefh%pw-hr@u9`tl476tVyIB!caN0C{vIUOd_;LW0-s6$bvcd;cpi&_yaBn49 zr4Z~j3Scj9*S`kLZV5ktbHI{Gj@exV5k|iJi{(0GUvC7a6(DJZ*Q;w7SvmkM)A9DpGCDV zwd*q%QdY%?|JeHrj}ld8K9ZQC_HTC)l5vq`dMY?9ISFQkO2s3c z{6t#+isT(>KvkL}wlwXLT7L9WU>wl&?c5FAheD()2^mH{N#jA2E_6PaZs z#K66nKhBb*NTQ**0-y}M1$_G`bd4v7&WbRB*ZPge;LAktSQkiJLlrz+_8HFh~??I%jA=u=-k5CaM-#%G67ACoem{(w`O-4 zF4p;Ae_&$FiV%y`9hi4lQ8~YZS zG4|Bp7XhA2uWfz)*W+_b;ZVM4y?#y5dAz>9Gl6pKk`maZe~t~m-k1?NZCwilFHd4( zzvg^9&2O6QdGhs^6o3z6Lu}&3(}RtHP9%3^GL$(c@JUG7K`L+Boj-w5&NMV4@+FG` zD?`*|0u+yMLN`H?=VC$wEgb5toB~Z=0wC%C(IjFXh=nrej+lg~*u7X>eyf21fMo^X zyVn6bTdnT(wFZEsR8u}2Eq8GWxQG0hgR|D@(@-f9DP!Lp5`jOmAf#GAZ00XP68X6gh}h`T8l(8TvgTkhZ&(*nw0p`b=?c>J;# z0U6M&N<#qTs&}rVx4CxV3LN2|l~*SuF^@oPBqr;iUN~Mnn2z`F%50d8RyQMWkGE|K zKvLC4PqzeWv$C2(tm0aLcBxKK6KW*rM?`|)gtINH9Zj=qz>%-tzgYyU8p{3#Kl_71 zh(i+d2&4pE%2Pv-6F|WY$L;vuV>0^H-vWwB+BOB?F!4BK!AVy9`^ME9Y8N_z8E0wZ zlfS;8j0YR1tp*$Nbj$0sa);{3FSOEV7GvAPU;NplrX7}FAV6~}e(I9== zClf$s0QQLhVdAq@0knVx&QWu4bP{V%o_>Pl%#<<4wjLr*fag>J)=vf?3kfl+!p3{8 z;a9{*gySZZcPv7o{@69$>UY}xdv=xSGw^98I%d?25i~YRx1)!z-R~OR{cKeLK7b3K zAw|~navN9z8=VBTW~oi0dugH)S?kK(dds;duqtb+R>hz`m+&U-M(P6#ks%g4O|iHTs6K7=kcV?e^?G%n1J(RD-t z_+ll&Ai!3pWMJ3~VylHe&ce=W4@82s$YepLO!;W662Dp%Ml=Te`n;k-S?#pw=Q*+g z4tAhrgRPE3)bBk^G_`!SgVtGdQ=$p^+f)CuoT0bvTVlD9V+z3LZJj>oC7JSO)^bDQ z?CnY{kt&6iT=XjlT(hG|c8atqr4N!}ERNwZ{`=@9$S*9hy~y9xq#7^_P2JbVACZKM zU25o;QHDJCBN#XQzj?W13P9(G#VbW>mQ;+;NW_QF9y(=t$hpJN$>xA_p?=r(Q%$^C z71E0L`SvMovqZ((IDHMG8Du>$N6BdQeFE+GY^T%()ncx`1f%{v82nKF-%G$T1t3=n z{eViXtH~{NYGJLw;Vsr#g{fFff-x7s+Td><``8wGLn0g>|M6KR;i3X;L2WQ=gw6EW z3be&*$NLtlT2wlJLfRz9GgpfsKuov8$X_03@t>F=0go024E*6RilL7A(Z5+_t(#XGV~i$qF}6Nk*AFL3JEU|gB)oL)lPuXS_&`hF(R)kSVwL;djGa%y)iTe>K z-=eZKey){f(TM??1SmW?mfVZ)Pc*j*VC5AHR4gprKJTaF!0v+N;*Va8*_#Yi_WEXk zWXhApHf~Ci<#zrAN>#Q%)T^D`cUnZ3bQ^sj~2bNf@r6oAKJOW4(n57U$&YrxU%huywVj29rWR=HgbX)vIv z67nwo)%uA-3_C-rnSca!b&=eR`2Xy0rn0-A`FD*&zpVFoWAtMNAwpz(tTC?-krR8Z zvGsX#_#9IJCX_lNGLXM~LAiA50;tCa8*Zr+o$eW91}D``G-?9x-N$+4C&#--XFTS7H1KjN6EpehC=!hoj|~ z0+1(&5gUTbjv`b^?$B%K2ya#xYi|AeV^X|MX)q65N5vLMY|(|%tT2=O#4pcFrreqJ z7R*+!0OK?ZY|NTfb}e-uTQ!DYx)e{OpCbHejx67pe8*O-^{&68YMYgVrMtLnX zal_r8C6%kj*ju-3?&SjOSFSFeMFDx=i?B4CX9o&Rx@)A}D}aeP;SSpy7$8Cm&K@}< zqmWd(3a*_;#kkd+A3eRgebP^pEi6=7hMvJ@gdONR{$F?WATAu?_=5auEcop&7_C`0 z^Uyf|8)J!1UksBY3c%2(Qlo%kWW%&IN?T&g{E2x8$|2oO#32{$tgoiXR?=~skOGwp zgT}EZ8gVDq-HI3`wp(K7_>jcC5wKkW;vrnw9DO+zvIBO1S1*7Y>}i1a3#yh#ac>P<`r=3huv0H_um?7GtJtclzK`~~g>Pa9 zoZ6NT4Bf;hBVelnkTSb7!i$X9Tm%}BOb}*v>W4>yip6@2I{fu{&86<5y0 zMt^RfUu5`2Ll=5|1Z-0P7gQk!ctObqJsP|)c4h@T!UtfHJx`QD{l5jk&UnE!@kngy zmL_vrxew)JT>`7qcoKLjfr8WV^tJ-(CRRh zLZ0*ha$BexIBPzP;H(Kp%AVcL_pRFPz?=tS9K?ZPaOjp!jIM18P@`L2srPsj#f6g$ zcjNT6whjYZ@zg;}o_k1MO?+>Z>|1So0XKfOl~e_cnsOOHx)F%}T=diSM@~$#trHPs zH?vIvdJlX`NLx-~)FIFQh>EH+whmRvhGnJQn_cvti&zW;niWF=h3t;HWI`V(Y~wTZ zxu5)(vLj?;8Jboz7ezO;wkSZ#45{R4P>VDA6OsJ-&2~a5`W8LD?5`%)o|U^`VvR5~ z1_^=t?+w!|2cWx@Lz}~tvnC{=5#R{eq5yfz<(F-K2drjUe2z%~UagbnAR-7|Gp9E-xd-AH1 z^~>rBMGzb@Kr2DewHmpA5Cq!F!V8Yy+M)nu8dt=^>v`c4gUEu&`Pcvv&@jWXXs&}i=sxr z76l**bii*fNQ%@cv|ydwFyZJ5^G~rS#lr>}J-&ECcXOIEBw{s^)g@G;Hj-%-T2;|q z)g`PiAfL^#9Y^Hcu`|kP$<8yEPhWPVJraExhKtE_N?=}6ux7A?G!42`60w>&Up7ex z0st4&0^jraA4e6r`skR3R*|Zp1b{# z?T6H#I%Yp=HC40@_F378Ok(Ekj1BS_veX1 zAA!CM8)fGxnoH7UPp@VR%@Z|QK+TvC-$ljYv-SU|CoZ!k@}!F}i6Ry@+bgkQ9RHi| zc>`t@{fir@Ww>zctWvUY@!tZ(n}1I8ivL>|z5Dcr`N0eQGHL{DQvlwPtw!6TH*h|5 zhT){M*Y1+R3*-)L=s*-Q+$Bg9Vbk;9wArN)^i=W>X#UG}A0ek`~0shhdzDI?) zr@qDAa9`7}m`of6;shFaXImA3&ts!BHk@D#o4ei?@b{2+hdW$I{BZ;V(Dbp%zMeN^OWY?4&ESx-x|6sGy;!|fa40FN`ROl6J#u$FI=s-h7=hgVl!p3 zM>FI37s93?35|faN5Crz;QA=yZ)$X{E9@*zEDSk%$RV@?3~j5&F4IJjv*PWLpSPdq z?C1sK-My*+t_Tr3$JFiDNZTDHKqi(D7@h)4ewzo@w&RvEyOt)&L^m8u&vW$0p(vA) z;q|%#xL%6g-Yd4LCS{vdRAeUC9(@CLfZvW*JAeLh%;(f4LAQU~sNgZ?qG0Z(Lua5& zfOa1Tp6U7p^PNGBEMV6)W^-dcQvg?#h`s^wLwNz8AO|STqN`^wtAD`U0l{toEjBK? z*vG*?)RiIc#?Q1A7D3#ey>z==zjVzRWT`JkJ&2!~0i7c|yxUEiXA>C6&5V%lt#dc3zo(FIKN6pw01u7> z!=*enNr20L(&xyiAygLFoB~96*OG0rbMaPHijes42;{3!P};oSNrN0jyJpClK{qs> z2HO^FlKt!U8Y*7$)))GX0=PbyA<~TK-cH}3Op^+d6kQ#;XAn`ka?Da$J7J~iPVU|s zR3pzZYqJ`;w|B0I#>s~L&v|S7bPKr*_U^f8j>ulj728&mv{}*t26F?Ig8S99^%pdN z-zk7=^0@nR@M?h&mjx>cp&0AYYr>=7Wu6LxF&>seObatZhsCcTQ7yg zKSzLtP_16;pe8KIk;!Ipa6_2ZRFedtAR3&MZd_h+f~60Y63QdHdDc4cwI1-eqR*}o z@OuS#@I(j|h1)gl&fF(Ox)e%}!-_hZtyj)m*81>`GuCKB6Gr(M{w~wB0{*F}|4k=( z&&r*$ax@a52y}Q`0!9J6Juso?c|HOe^JYd}ct1%BLgMp(o|BB|%tN*dQquoEY6<#9 zA5x+OR@UVLm3|GFMXc@S#y05#(A%v3!}Et){6$CsEDm7k=YNktVKhl>3uX`&_0Rr# zisp70$RLLk6JmcL$+>ObCOLcPl$Okr;jhbo`zSz?{hHak&D#=E0B?_YkUlRFNCRs3 zYA98Z&tb@V)>l(>VRP|UYExARf8l9x?#OB7Wo3sSo&udd=mqM>GpYcjxbe1x6u|Zo z4< z0p|c1{}COY3HP@ruTt&a4W&AIM34@WLbwB)B(hiZ!xZ#9AqrP7raONYC`33yq|-i` zV2Iz<{o8ncgKqkgcQIRqwerCy?rnyO_@yXf9~uIX@;%eE8D;aMMB}2gk3)wG5T}o z5t;JQSh<3HfG@<)6u_VZ>FpWz#mw0BOm7VpFO}+YZjC<3h z6@i|zDwOwSHJz3=K=l;Y--c{|gFaV_w(aI}XqD?B?D+mf#A3zidcpg+ehFPM`=+_% zP8bsVMUC?z%4MJHM|O~VbV5#)gu1T&>pP2!t-ZgBgL?IIySpVXRpD{LGUC{dWRT+- zup?QD-KV0GEyC2>LaR7l&nlObsW*%6dz8nE^)I`gQ1x2U`5cBKZTJfEsuZK_1ekSK z_2_xF3N=+V71Oa80&|db+a9$0NThF@L50RlJiDXbW@o;97!= z*6z1>>Uq5i`KDcjqp)Dq<(1gYoI4ulbk;Djh42 z!D6Gs+HWFu;?7>Pg;N5BuHfO1jio!D@CZsIOf&=$i75&E`^!Aqp9NpMK`d}mBraaH zUu{B`J5JNYO&nejDW)|YbZd5k)$AgE)6xVcw?jgl3`EhA_y*2Rx^OY5e1raey!^S@ z@aJZoY=qoS7ldTL>S@dZnS2z#;2vnSTn_t-#)+S0bA>>|5rN@WR-q z>&oq#s=#B+AenO*_*MS8=A!4j(|T=$cl^>N675^&>P}drk50{tiDxH zew3GuA0y1Q6t;ghs8(MGgKScn|e8Sj@;isOH5Yyy-L}3P4g;9OvJkh$ut10DWir#$B3T@BcKC+H*w+ zoD5ZXG{^zywlgqhf={i8`cmb0cAGwpj6W;ccoLpJO!_OEej@u;a(*WXA%}lNLD7xP zY_|qn-oj)gqWNL^VIf1~1fuGnRVt@*ittQ-51$}xYp42ZFPhUCx`m*U(ZORmeP1t; zY~b|VGXC?SNU|?4d=|AV6tr!FRwkWtS1%Wd1)4r)tAPt*jr#O0NU6coSt)d}UnZ5u zo673cF}|;`Ev+rl7A7lXz%t2iUqE_xA`;$2kHzefW7$}Lq(yK|b)~PNIx~ypB5T-G z^?KNvJAM7|6$*TkKNy%k<*pew#NZ%zo?L&N{-a}Z#v^B+Lu{SLQva{FZ;egYoq=sA}lIMya=ppFE-m6a4-pc94iK?1a5u1vF)a$J>>4xa&k-{iOvSr zusrav$jLdj4<8f83u=SQsQ)c2jgMvUN+2dGB+V3F{xJVq_w4xNatkJJV}Edmd1)AYhd6!ob9O+|hLQreT0+zzyXMW$!U6 zmmHj)qJn%D>m3kZbiosey4MXVG)ieN67hPirYh7tW-5h{%2N^ZEFr~w<9Hktz*!RN zvMTIb)(~)l*C&bY0_VKy*;R$rYST-DffJxMK!+50@bRKM0y2U9aQoCwBa@uam^)qq zbYNZ%xC7YyB%8EVe0}iMLA6Ia(K@i+9Z~uQvQJ*TD<<9M^LjhQ#R|JvGxF*v5^Sz^3 zHvzB=rN6zOX`5Na^5$i24k8oetGE&M^M$;2GqGpi2bS;f{=A~Uw!cDeHt63$|Bg!9F$71__5ebGF@YF#6>au$7+{^;#w=lN`wdh0mtedRH(~~8&~}*Qu0yT50UN*v5xN0?ImoJwlF;1ZmezZ zxVlbc{2=g(p~lMj=hJ*yEf>g0_paNx>lC1X? zHPynr|2}>D0aiO|-^y)0$)9>Z1KEeS=uAqK8uQ%zW;4NH+1SxpexV9)OD%3bN}?)W zZ<)=YIYtAl{e`yY!$9hjOXw#M!;a~2QwsZf($9!@ zRIb}lid)(WK}(rVBKSqq4a{iN&}Gj&#$=X`53iVYSlO86cxGHJl(EnHpLarZXNChC zck|tKgJ7RApC=A&O|;)#l3uM8rOgG@WsjU#XFn!A;ne=#o9pwxB9X-1- zb03)Y$wTM}DQj&zkKvC+#>u)Y%acLU1*Ae*R+wIau1P|0*4Y`iCR{|-o%0`%Jd@Ak zK0zm3=a{?__L&s?9{h!OFM?HO3xdKMzgRtiWHzmH7N#~mG>PvxGzNVM+q;zP-&UfY zm+nDx|XDS6CfqAr*7c(8e${3pLztp+2z=^#cOHU?>- zIL#QG$*X+2gj?4>*31E3;Yi@IY4AATbtQDAXqcR;H8 zY_+#X75%dM5s`k=|GqNrNW#bC@3iRxHN_mBND?|n|y#E@*TK8N9L3{ zcT~aHoxdEwV065620OkE*qV6v{K8p?j=L)D@L&8{R_=5S! zT>?cVMA4X<8qb~dacpGeL^NYN7+m7?YL!#Fcs%!S-~6M){vh^*cr(yhDivh<$xQE| z6jPi|(7%Cwi74#tv&qS#qJ>C2=Ay2KX0qBo3ny?Y=UGhEfzW3M@_Ab$|7 zb8@>JtSM6kL3U1!`=1tBH-E=4+zFE0Z5unwEs$UL z48IC4a*L2|Q6bFD+eMbNW%f-R%}QM}5{ODgNI40h;=sPrv05HrlhgplbXegh*;kq6-w%3Cpe6V;Fqs#p9dG6`;(5M(LH?O%*@qzUF1HY~YA8wT%rnp;h zCSAnGK4!$Z`(LMV_53o$1y{;KPmQ9f7%5j-IlFbqs_q*K)|=ROvqq9?8RT9`B%jK8 z_!NA}oBBkbJv4PWvU>B4RS-mNY)2LwW7k~ySvtj?NvlGyDI&OSqyXCk=C^kji#B)~ z)kPM`-P|e2rpXnJu0q_PsIgjr^hpy+x@?L3|p;4 ziRV%HuhMr&e?}9xz;SY#4l6RQ$D;mO#DvXeXdG65g=$ex*m#shNM8--?Dr7Iz~t9o zcUAb#{kC)RI0lz?--HeoF=H3hk|*Mt;@if(*vI0J|3L#1W+Q`0(Mr2Oql7CpK3jht z_{ksz`nWBjAxy~%6B3(;#Mbo`!B3_u0Z64y2+h2D#CAlafdrh&1geXE+|zPs@x>nz zQn^1M-502vvnyZfBNz|Cf83dyMM=lKtNGN3MNVP-Go^1e)Jc&(!no6wts+6na{P#u z#ZQpV1#S1~%pe^*^~Xu1z@Oh50y5S&+3DhzPE<%lA$`Lyq__BWYN5wEMah!$6SD>}| zNR|}dV3JZh3HUljVvI4Y;u;s_8cv7!974o{_;Q=}MKfg|&dJ_ao86oXq zpcj563f-0rPmNdZp=+t}OVMd7yso@yUc0xeH9{7GL#3rF5*FPiF8Bb=Rn*S@1&_9S z(K2j9__ZOh&(iJuXQStMam&aJG#%o=1Dx=yF(WwuaKR}kA{g?Y?a*V zTeRmZAGLd1ZWQ8NG*2$GxwU`DY9GVS$C9Y~RTy34V3PUWF|1*{cRq1G`?fJE>oM|{ zh|`jqc+8KZp>e^Q8I29fT8N$DhA6BJnk_Z`?G|Epn2ax$XHeHen(1;^j$Mwzbcih* znR41!juE&En<28_*@)cH69_VcuiH69Hc9#aj^M2ZwVn!i-WOMTj4TlGn}@vtv(s7+ z56Hugq7}9-S=W?%Z>MVWOi1Vf^W;*U!t3pI_{o^*U&iML>kifB6 zV@UoE$?z+dXb-RaKQocys)4`dLC7<)kRSur-S)4mR z??TzaL`+%!!p1qx;H)Rxu3lIQ`;~nqqDPGIw@82ydT#XZwPknMAAAupFMpduY#c$x zXz~@TmK(brX8je)MumlgjZ)Y_q}|$|I?>wO@wy>z4gR`Bwq`H=8t-K=FhISEzOIO4g)p-5RBrYK zTdzGwp>%|%ulUCgyfG#6c3=l?;9Q?q%tTvp;^&=XDpeo4*35eMW{Yu9v8WLDm^o8w z{u~1B3>*h;P~HRe8TL8W`)dL6ml|2kH^pk`;|3NdZu zKE9qJ938m_7AUcfUjMtFFpqV0p~u7G#z-p8pI1w?xF$QkPyXkg%%?bgKVeYF2_LM8_KdKHtPJ;1@rZb9JUKm@(xp?Kb z_GM}5eI3(n5nKE?v`P4a-G7-fW_s$*xpBNVd)p5ez1kv4*>pjUZ9vTL>?X;e=2wTQ zXk6^qSofkOVFHB=zWmX{ zk69^X(kTYsH2IhB-Ic-w+ zotb#6V^F>eE!#quDUI1|D8K#Y!oThp`_+%HPz|Ii(QNZZ)6;Z@$wBlrDJQ#{Xgz7B zLybWp&(gu(Y*g;G7bwLXELNjRzfTwNy`R3K^*ro07Sr`zN>kXn?Rn5&ssxd848d6!?JGQX@h$K^N9yjXhLO>FSs zzD#=_Yf9_WfG8)~8g|VwiSEfh>B9A_tU&vfWBfsyL}p;la5iK#s(HTfrb(ZPzhl6M zW{||lT-_#OEdo$iiTwI46HtnCU8*W@hAtn(QGOGq)n*38OYmqqtlsIes{(^m2~77_ zNP7G!S0n{bv#^u+RTZK3_*k&WN|$J1Z!@N1eR$(77dH5)iHOlCvFDvM=ASXWxCf2o z0`{9StJ%pk2C(kEvJ6UKuAD^#o4_=o?yC0b$cDX02+#0K@q`+BHO1f9B=N0xBjNUB z;aPgvmW+^P2|1cf9y}j>-Aom&2P@?6Cq!G`Ipc+s`Fr{|hRg%{6V&sIw_{H9?G}T| zo2@NBRZFoV!YrgE6N1F`2Bu7*r)h=GISLh<0r3C}2FK`neW@D^q-qmdv0rna^*S|Z zB~$JUNirz<)sdesIm@E3Cv27EBV1@tBvS%^*oHeQcsq4rUfCz6mH0l@N1vYI{=Cq9 zFT=GUm_l=ZRFd$5*AZ3PC9kNN0_0Ar|#v4uTWv`k$grE*{=$;z zD7r86@{K&J|6V|m!r1SAFS8Sv4wLl~xQVKnzGe##xLAVUMPE?X9cs-0j>7E+f}a$aOHs4&##e zmZE)?v3gIxYhL=~-@OhZhe;f_!*34nbYYxi*(r$A!6FknE<0i?#y`Clbqg_-+F;uz zI(Nk?;wB=$%+N&?5D)`ehu<>JKJvYK#LGkQpFTXK4D{8re#l{IZm};D1f59EzSyN( zFdIs?NSh+m?PDkCHAtbiQP|`!)iEOLvl%{b`n|YC!0ys?=}z%>bze^_W6FFt$G!`= ziIui`lZX&OWMIRGooszKeUVt|i4X8ixBZ2$QeKIeoj7we;vbeu{II);BdZ}oZTjvc z(r{gfrGq2yUq0N3@yPY)4&v-eE$LxvQJ-4e_pphL(q{O!2*}@D3K&Y5N%J?3=ch7~*3@a?B6#f%29d ze*&(38%We9(Gm{vJ$#7_bl22fL5qJe=9~R2T~Y?mr!*SO=OivoNETJF^6V89h`{aL z`O`{P5tSqa_qIqL<$-$xg{C^>hi7x7%ks9Q0{#)q9)CrbutBb%G`LdoLoj^G%_$$N zQc>Pz67$~>C9jI%J2(q7`U1a&&4N-CiuWC8sXU~)Po*INDz!(jI~KFuVY^dX_#3*V z!wfgf&ZQ%T61WN50pT0CYp>$v!=G$QwVrBm=27;73=~hhEm0 z^dXE2!)Y+epr%$i=9S^o_nvr|?B23QVh^_6Hts-!F+3kd(I zU&xMEc{q5X+nJ&DA{Mp(tzISkMWs$wvg=l>TAzt%CY!DIv{p;oCr}BcysWpTdR^>k z!B>LAKX<2sJ>Z(w^{f=qFLG#PnfW+$9RyUiI>?zG|20b#i4?4EdqFEvPXSO1iIA?;$Y<$umouf z(~3>3dt-A=8<}p%wm})(`lvhc7|Wn5?qC&NO;4ulPMJu}pYd$3UPZxB1SzuDlGN2H zwQp2C)l#iGbiO3V4Y@={Qi;Qm4`wDcu_pG7xdS-UU9hy5NYZiGzU=k&CIH*9Ac!(z zU_}~H0Y2A{U_uY17>Kn+nrb5)so#I+Ta0d1QEzQLN+zceT}-^NBIY?!0oTdUR~!9U zRG2)m#t8W?2U_D8x{nfHLy3cx7#p{FIKS*!dD>s;-~Kt9FZrn!2g#q~hgJ2-W4zkw zIEMctF)Pd?^bUPF=#@l2F9qkWRV2UmA^oAQ1>h?VpAxtp6z2-a>Ye@|b2T+pP+*!? zJ07w@V-qIB581O{-?q2kL_hQ-+yyCvWxV$Y;kzz&J7~^B?F5CW-cH~(i_4-*QZY@j z@Qz4hYDbEnQwSn6U;HL4nO2g(WQBJ}!LlQV zC>hQuAF7ufIsu0g8C%L`Hz3atFem_9Xei8^L}zZvp%Rcr{X!WWyU4E+zS9NtEfOA> zD16}j7>Vx+BF= z?_5Q899!^u$G5`2xboLb9`fR+=~ce_y6w`RDj#>LY+Kj-Rg!XNR(ZfYYt=Iq{;}cJ@W_s$*HrrUdB-<*2j=@WP z-VS^CG(D5HSCB7o+wBiGtuGcBxN_}XER7rXD(rTB?LCqX_3?5 zu~mnB{IJOJYL?J#P7fg$r$G{i*UW8+eVZ+vCs2`)0(slYTV1CvJP)yf``m8&M9yOY z%(19`t!O}))jG|H(#0;3qa>{9#5FQ8#VZmoqr%jWz%P-p#5+zxVEEcfJplp4@+x%( zYAT%cRQWGp4t4}oE;6l#U*=X-)3Ok@JiV3P5}M;?YvggU&3o~togXD^|KOFVX#(%P-|tItna+D`%8j==6RZB&5YHeV*}*v~ z&qR2GgAjJXPMq~1(JwfW9W1fR7LL^C(bVEw=vCA?q;$b9Xx%Y_=D%Y0|5_E|TME!m zqrOlbT5fSECN)U5s$Hq#GClic3ZVO6LdBc!pEC`e{n(VH(cA5M1#kY)tbHj}MtQ6v zc#CEYYw40;Ck4?*jIj}1r{4;dXz0_$iLhYH!m*O_zg}8hv4ml8YamiioHUJT{bOLU z$v}l%S+BPcW0(n}48XCLc($Jq@~~zxKQbD<6#AQ_{DoTDwzPdWK2tOwJT=wK3(Ev( zx1{=bK41j_PUpHHd+Y8Q(*zqe$RbB%tEcp)3lO#85-^LU#A_(F0|x1GaIBnP zqYhB zS9ZptC-eGS(4ArlFozByWAC0S-6VMx_@9K-M}l2$Rq;N?5RQKp#X_yzT_ zSCTy24MNC&g+2lifqSyqJI3eHV67un>9Hr|NyuYrCTXU96z8j0!?Fg!*8qz;;BL5E z@$bICssijutg1!>`v3i5;Q0^#zkjiA53lVj-bFRMcG7O(JXVPz?mRJNiub(6&sHlv z_8#H#;N@$igMoJV>TGx)@~>W9+_G&gsRs zY9yNtrynZKbsg5vEs+9JEaOPKwI=e?6u>1EF0U4Wd3#=5zhIDg7@`WrD~%3L-R@1A$+9Njz`I0(q(L`+ z5aQesmsLOmAg(Op__j6tH<~rdPPd=W@tTBwi?;`Rep6IoTOCU*yYn)D0EHLldzrV~ zH0xFhz{Q81$>}fu3@Znz4iBRDAVni(K1DNazV(?yl3aWfBPgkw1JBVzzv$@JC>#h1 z$-EO^lE?W5H}PI2+~D8aK&Dd+3p8G>kv#TlSL`HU5GP=wV_y`hcI-k|2)-PC{%+&F zKozyrP-TM>pCs_f&GU;NI_#%1(erV6EE9fskaa;nK|htq)f`(VKv;+fQ|K zit4n18Ho{Zw$+96mp(lvMFxEM${^^sLNJo2e%!^CIr}Pg*v6W>jK#ApeIf~6YaJ-C z1bj_1B2SASb#kj~H3PMqMrvd47=LC5`K{1#GnIoG&tAdqza00QU634Z{n06c2%ZQm45{9)=h+*~d=;NPvi9%3t;#=#P|-o-Ty2v-CyzV7XNaB{o1(N z*a9~+{|Xav+wIxWo0hHx zBzkYJANUTO&ZRwN@mj?Qh&E_F*x^Am5tq+~tbrhhaBARpl8pF{_lTWuaxU5Lk%GEf zGQTd)#iI5tGW%NjAk-LPeix$rmi0LEZQ~esYJvHI`P8_g(pMz5pW122y*_H$L(0v! zNvWZ|e)YAPXdAI6SqAm`FcRhE8c*Igv~0e`fyWS8*LxFQv`49<-Lplw8izakttV9b z?dv!2q|T?(MBb;g$l<6REO?HNR|X4h9xeU6IKZv;M`};T6jLRPyX6TWK>Q+QWm9hj zT4B)g+i7~`r@m=K&Y%4=efna)Sk0nX9-Yor79+cp;GCIVASQT(nOtn0{>MTF{=5+4-?e?kW-B;ISzQO$exWqa=LQ;bQcM271WHPZ>+?a6r82c13Sr z>{E_Q-%hv5vUGw0Ypgy+F1O=0c`55Rr=9_lEOjzaqZBD2!MfxiZYSeO<0b*JkAlz<0@fb26F%UX{fM}hu$cV?{_|QLiKN&s zykCv_q2E83;`1Y*2j<0@5bqVvd}iw$3LrbRbMt0;j@5}l{=(@3n~#3D3g6k}IsXg{~<0TFfbo@LR6=LC*)K%M?=1f z(KYL{i-m3eg#TtN*G8b`AUa78v&|j z&@z1a?FGFo+FrwKhbqd){q?Q>34Wx)AkCL`ZV=Y&(fAb#hh!QO&XxB}-!Y)mjfR*m z_+ZvsA05Fwq7WVmC>!X@rwEYtkV%G!g)Uit2)Wfu#KF(Sr*@Qoj2(pUla8Ow#Kgmt z;xu4YZbczm{IQta*X>_8ZBHzbkx@(2aSQ#}DUQ}Q|%)gG({Y}U>)`YU>P?cZDwfex3g!)}wF zV`Y}-=MX8lC+>l|v4EGkFccP4IVOXB_7T1*H@8(_I{jtLYQz!wO3tK;s}b6IGu1XTD!2rhTXsPw+ubBH1aU)BYXhZ=lhP}AD7-Ze?cu)QsLLV%`%Ol_YW9& znLvb!5Q!Zng|n3pf1h#6Mw&_c$+g^fcuWk_X-7!yhun*fJF%t~gV$T(oNuq;_^YqN zY(1UZ^hC4SvT1lGI;-~S`z;~rsJ{K^mMJLW@5H-$hifhZzXK6g3B64>Nn@K6AP?|e zLdBm%6{%`YLf%7O-x`hr><1bEeT=6Gx!?bO|JIr&$38>(J+>tR#HUdz_te4Z4^ngW zm>3?~EEg%I>3YMVSt-W?4`G-jY$+&=(L zrW1g0P%{2E9*>lFqsm2qHCmt{PZpb6*h$a`8*9>*m8e4fPCF>71_9LDS_U$Ve zTy|g+AIF-#(Jh2j#fyFY3j-uuNjhA0up@POBsSbN5fNP8MocPAfkGpjAnMcx!{F;U zfM>pW^%qqWH@OAg0_XxHKTG<*E@)vJMMqQB$_~U3^(f)ynC>d0|MavU9lm^MvT}Ps z8>Eo^VqiLG5c1R?FvGV$x!)Jil!(&l@VYwQLI(6pWjGy6JU?CnG3W!+7RmVyet!HX zWR$=Mk$iAP38y+hK(n>U8vliE;@_~1_C(OB?k8jJmG_d(42LobN3_G7mCXhZc9YXz zG&6?3jfxdeVTqZU^gYD?^zBdk16Om+K84K|+7#kknFAZ%$GOR_=};p2&-!rRzay9j zTij$M*f}^PDA3pFinzOiKA`7{Ws9eOzP(ua)eN9>&>O$=qW0`?)=Y3q9}-9%@{Fwh zJU!0WfgFZPkS8eqQgJ<5Wla!O*;C7oLN|3WNZS1WK+@DvBag`G_HPu~OF>T9%zc+q z3%}H-O;X+le1C_mi73AIFB-uO=#&Q0d5DBw^JXe~M=A!VsPy!SDg~(1|I(#PH#D@F z0yQx6a}$R1e|YL{rc9mq!IaZlg0|Jg;^eX7$M~9|?HKoXU7S`*DY3)6hw4xhq=A(@ zE=7m+y?>Cg{F3`dZ~Edxlce!Nwp2K1|gI;qBkq zj=G@|@M2Ah!KYVv6W(TqCGfmQf0Lab@{qT)SDc3!ka>Zf`B#O|=oW=}RdIl=I^~M(zgY;XPaSaLYTq&K0r57onp>bUmkpc8lfd zdlGH<5`)_&0@cSQu>?h|5(m+#dl(#V6$6z~??Ii3Ow2jSd$OLi~UVpQy(gE0TQz?DcSQtmsor^X|#;zF+$< zp_SsMGtjmd^5n};Iw4kgK0paZZI7j6h9HRLvy%UFBLA7uupv2C?_#9a-_yR3eI-l6 zXppoPTdt*{W9M|KqixIh=)G^&Fq3nq<;(&ug<7eMd=^&~+b4|pDD~d$e8O|#FRwZn zaV&*F90+l}VwrSb7B_xFHR?hs{S(R<_LzOlp^xxJ-nO(9ZbE(Q(*6GtNB5PL={Q=^ zdT-yQQ`Op~3g%Mo-(sG{tQ)70mJxq%sCF@A-T^A$7d|=2U$c=Di~JF>6EL9DQ9hsX z2f+>(o*r&`_mIV}p91i_75$;JDbH_H<)p)#qT4+Ao?!Y}jeqEU4w45vcxN{-7inIg zpw-u-tfKj-8t~Cp2r6 z%&(UDsg7ujB%kDEs%}8;F#1LcnZ1rX#;`tTKyXfUe>YzE?-BsfesM~q<KUn`PB=M?A z0v9CY&r4)h2{qNl7zPqj+bEh4eY*ot6Y-?D7{!!!rqt&`T^GK6?aFUT8(+)fP-dpz z^DLYX8B))f^6pE9Xx*#afDnhx(Go^Oi34&hXIj;Ve3#?Pb`sQ`?uM zuJ1CMWYAgal8%mGkj}UAPsbuZD$&w-M=TAdBUy%CbFY-uU>e47%3Hnf!Orn|r*X{o z2v#y<7K0nY5?8man zqlP!s7mQJ{Y9Z~Pc7VHw!u$*D_~NSK8Gif`d&z9=;n17J(H3mMkn9lkFjK*)j`s}pIxWrF3E zqU>_4JuHY`TK@jpwG3)@TT9UydzBj3Y+Z?~*L2k25F!WyKcM|^I#_3BU5PT+JD`c*9>WU*`xBN56&%)%Q^R{q@pvEfvD4qQ8k@vve61#cPY`{YOu9_~1rq@mw#cq4kQ)p~F zZS-BOM53RG0u}^&C7<^31ew$J8p$zu0zW0v5AE>DnQ@FIu7_lD4>1$=l~v#Mz8v_Z z(9eKR;Ia=&p0-7?4Ru>J9|WCGV9q(7Z9u_^)4p`lWb5_yi}Y<^?JXF6Fd1D;3~2%@P+7ufR?vWQnIp zH%`-Ir(dne`gQPiKb}9i*{jml+dF?jWE%VI6|cyJhZyW~jim0{R@foxCh=4>I?7Z? z-^lL_wW+^XJ>XyfTyVmffKRzV^fjnSb|_4!pZ^{Dgr4ReBgLARCU0x&(AGd}H?nvM z8_L-D&FaIKm3_w}Hj%6Ht!&q}JT2!>Gn>!i7CI8;&v6IJ3xFcI1;3J~N6O>4bSQYV zd|12cJ0~$+@I!#MT46cYkIx`n^ zUylG7Q=nO{nyhZwaqQ3dlv$~ESn}y&+m+_E*LI6P2ZZAJysldy85z?PSkCKN-ixs> zAO#S8&L;aN!Oewu2LnWaq;SM>qGNxRG+>UUg`kx(?>YhLXaK7KvD>6u|0{fNaEg#A zBh@gHh#i?A0?~PPsme%$w^hj9>fiI>qUO+l=q>?ddHqt_-hZ}n$5blDN7ppF=;QFr zs}6ndCorWrI>G9!!U(N*IpdSSr~OU*IJ4vSKSZkg*>{+kYKmGPntrpd}X19BZ+3SXU;4OgetnXjIo?+GXPSAJH+ z9;#f0&U1gOIs!FfhwCnSSmJ#_hOhqH< zf?N>ZR^kqK@gQ!UE&8gX4H`ERQTy0#-et~ZxuyJ`zt~q^01D_D_q|%|qP$Fi=glz; z)H|``XOWAR#epU*9+UIk>9N6Y!~uWl!QRI;Qcd=%&I-PBI_&Vuo~rYKu*H~zj{Q7# z-)IyAtlMCK_brh>mDOjik_N?5>CgC9n%|^gP zC+Cltu!;~Gb{sr-6E<|bw`v(4OL3+KrX7ZDjN-b2y|A7~o77X3pWUI`1o z``if!2yagv7t>mappx+p>h)qIm!qjS_>X&jMEtIM-y7PaiiU7IOZEHBx&8*F98hBN zp#VDTB-zx{i2(e%Os!dnl4~4d;Xr|0alRa{2ZXgoR}IW=H`wr<>3dvv24qmwsml?e zWb%~R0lLv1Jig2ZD0mWrH1ius?mkru3?x6keJ}0-?}49ps{#DkW7@+W8rElvS>(?G zv5@s0Gfa7?kk~THBo|*1lii3O8R$k7$LkuOx^qG>AH*SIGQs9uc1gOrlrfmvnpT@k zYY5dxCL&wUKKRO%FD$Pj3=~Ws60BH zbx!AhCp1ol7fY|nr}_M*KS@K^Lht=xVL|rlhjucPaIE8|&&j7+Y;XXmt) znjqnD!T@`-W&MZ{C?#Zj4SOhU0AGjGtJ4FpP!_rtd|u*cQruxA&1vUHQ+2DA^eYi` zV%VVj78R%fmMReh{pG%ghioASNxu6A1bKU2=5%7YZafXHW%C*(+2UXpy!!^#oi zw^#lJez)VQ5m1+xe0*xhnFd(LWzU2W{2BBPyl_d+#MoEBJV;db^7Xgans0@YS5tqw zIeR3mhaCa|Z1G3@?>%=*P$;2D0HB}%-ol$T4lA6m@)Ej*NGVH80YG}xpZM?1O|i;u zF%owqXbJ2e^>i3<@G35f#e9&jwICg^{r_(L#e>iu#0HRH_<G%3WTk$)&ijcK*Fj!!c=av4H2#_V0Wt&zRCnW7d?OtMmmrS@>#M4si&F$G z`T^t0Jw25j7Hj5bp&uhu>;@u|o<~wu0G4sN8RFa!;rZ8kAOZssBxiSd{yEN*9m*Y^ zeuPkj9-=VrEeRY19kos*2|a7bqMIx+NA^w;D&bbA|uB0i!PMfwRwBV=m# zyM*I%*2<)}3D-aZ`JGpEs+$8 z@V~~z*PpwUz2eO~ka{+O3%FQYuPpjb+_Y*@wg7~{hWlOZKXQPi_&oCBdyEFJKa`jG zu!ZCZ+6s~TrMagtgPCR+w|8dvzx@^G!UE!?L`I(Xxti@H9Af%A!u6fT7Oh$xPg)Ff znK##r`?vqunae@Iy4XV+Sd^&{Z`A=CWBse1Fp2lSlfpd3a>hyI=)0JCIbe|yeNeM)P`ry?8-1{aJ8^S&q-h8%Rhs)3l{GmBU4&BHK5U; ztMRUrz6p!P8t~BQtu4=)x!fI)^pARNaZylHr*3bc)og$0U5FvBE7Hu`ZlBHZxLuJg zY*cq_M6kc(AB6-{a6uZG z5pM4j-=!hw7rT)r17)lL;Ur`PAMbr^Rs&q-%UIX(-uaa%p(la01{GLKR8qaS*b3vf zEZu{wfIS_NC~=_dO!4$HtEJ`7A{m#XvbRj5h^jy_r!Ks>N7&o1v0YptgX{Y}qhr|z z#YSRlyyiua|3lMR_(k=7ZF^=IVCa_aZloC$h7b^uMp{r(x;usjX{EbEI;2ZNxTE^dHw-7XU{%suf5iNU#m98Adc|>H>EN&v5=&wF^po3B1J+a_w?q_BLr?T{Vz}kj)Rk9ECv%Vl4};( z&mUj!V-p9k;M47Vnz;O6zWG%&Cu|)tNp3f^i>;mG_zTb~n6B0?Z$Hw>BLMqUwm%x# zIfbZe3D@57)wbfzLh@- zq(uwV_;=CO%I4W2egUB0|J;52<1cAuRmd=$EyE!Rfy5rhuR^H|40p)NfLy3J8ywp9 zH;Vc0L<%E>)MmJevkm*%;hR;jcekChKD7U<_+?5q>9+)cZO6&(2BrS665-^`8TzG z6$)eHw^y<}tza*PVuGA`mA}iIX7fvvgz@<7M`{pjPRuTgnYf(w-Q&Lw=^4MBr(Wz> z0ja~GKv&ucfqN0$el)?I#JUQ5U)W6_ufXGM0h?xh(6+{kTIns&Ndd4%C zE@GeNL|9An8zMm=s&d>E)>B}^7T16W_F&Z)mIk-vs5x_9lZDcj_{0=WBpAh-1|~CW zELlIs_sAxsx@b4q)?$S%&(@;Pr{6eEo`ez31Dpd2ZcvhxJ{5>4wfkwc$x%k}j0>>v^$YuW5em=ZM*q(GatmIkzOF* zpGAY%4pO`Ls-)d&5;K!xhG(k>Ez0j@zDp%07;y)g#Mv{Tzj9C@7c+kStaHM0-n^w@ zl0L_!tW~~li(65 zlBfmJ|5yh3Dk$Hpc7mYhRoGGMPf;pv14Y`uZg;s{=f@cECtoT~5#X8$(uoS{EXa^d zP*MdH{j|pz=C4qFufx$7PD9+qJ^(;An&}wdNf-1vVdtY46_$nCWcm_A_C?`sfd44L zQ6cP=430oa7V+><(gph15P&fmWhud~82>)BbuBLgplo~%L#4phfX6U}I-8sE!1azc zd4N0)Gz$R*c=@{cVc)Azm=4LZ>Zum)k1X_10Uc<)U5rysM;$gVr}alE*W76r~voA>%1FVGnE zJAMQJ{`U%%P;ce2CGEd2iA?7JiT(`)ySd{!sPnZ@+J}5^uYW@aiclVz6A|5!93x||$ucN(II;tE>2GHp6* zb8IIv-6yJ362R8~nK{)Lw6k?pY~--1a5>X~1Wkj1Qj~GA;5+q6VYuK_gH-|VNSKMK z*MZ=93;BfGxXdd^M2J;D7--s%NhTJ__bl7`xQkopNnB!{pa5<534~goAke1(*D#Ev&k%RkK_@9>KbR$p4r& z?)?vUd_)sJ6$z83YUCa{=gY9-v`(269>l*HZFST+WU>KI5(?}}{ZGh8xnmJIn*9ND zE89pA>Er9zw_!A0Yz{^(OJ07jZx;dWgUr7&9(P-4f->4Pj*NM9oYZtE1uJro9lr3a z60@*@yao=zg?Pezc-rIp^KcQ15}n*Fe{=R^qOr?gf^&fE|M$aRJ%Bef_RLVm|4%=R z&*FD3wM5d<+p!sjyzZ%~V87Rd(W)#$b0%ItQ_s6|l-a8CCO#)qNOsK7l<-u=0}X!W z%}v{d!@f5siDhB0&NM9}6jOqRNS{fJGe!};rrc|o>YV-Tq_)Q-75XOR*VCBfJI!1YC$mr zAq`sa0P!k{gaDE)Vp!^0zwqoy$HPIxt_>2z_)Z`zgOi<6!yW^CcJFR?^67WrD?noX ziV@|Iu!l8Wtu~%+PCXz_xjNrsX)4kTFlzqU4VB?hk*{}q4?N;CDZD2MM?|fXmuU-j z27G2a@ps$SQkKT~SZBxf`sCgg1Op65)1)=6?-E73HHn~efmy{qoBaSlp2RbPJT}>6 zS>#eiZK2dusT3IfOqLyq903z z<ucD7cFOThaUcQYERBu&6fC7znBzoW5XW2Bz?j%-}={-vE-`)7Bsv;8&S~vUKHT; z>0d$d$^shEG;W%c!t!HntfWtXsrPRF(4{FL5Mek&H*g9|X%+H;99F{v9mpd2f4yf^ ze|w3HM|qQC0EkB0Ge`Sv0V8_BRwoFH2#xWM4ihbnn+>Ur@f z9;t~COVg=Q)3V0!I67X&6)wR+&^<4b7!ov9W@U8kUAXAvX7QSz;N6TN@~Ab%+XAV(%}E5JwMJ)ZU}Duo+q;cwkhP20fT z`WgKx->Ab(@dI!XHj+mgpd_`aUluYdwok2L164mN+Q*b4;EQ(ot1d_Y0 z5970v1=A7cy_WqR{O14;u4M?eEw`R1tcB%S?kK%N{7W?;0*ru^*&iiQig5!jPc`$i z*9X`zGF<=U9m>KtmO2DNzizxOA^_R9P%1~6%>clG`$a6Krj9394aw)s$fr<3#6Th7 zm;M)u_*J5c5sA?+FG+YczsY3gVPA})JE!B2n`8WJxUMAG(P#KW6@%s7qObFn4;i~+ zr$rk;dtrcsLq$@*Hd@M7bDGhAljdO%LRg#(HTwwWaacDnBJ!+l6x*WO{l5UEpLrgO zka2Bx*>5qn64;P6Wk#Yy4vE0BY9PtdQt;Y+kxv@^Rm=gz=JjD%!N+qmH94vnxcuL8 zLb&9k5N1;9dU!-h%oa%YplymW^Lf5)!2V)MQ>XtbI1S5~l6rhu^i7^T17=qOo6P0g z)DRc<_U)G60Rpdv!a+|4KUg9@+-+-yoh~kVYtrUJ=EXCo7IGU6=jyxB$nOEp8{5-x zFppHyn=6 zN35MP^aWoOZ6?!Fv3dtk_Oi?8)A7s^V}IlbUmfEYg~WshGEvFp1-nuyLSBzF95?8C z753KCj~RR?O|N#^#P`_T7;pWCi*%P*6qXkPfO-5Ut&w)m->1U?8U}4^Dcm5LVFJg> z_N)|xpIGTgb0$K=IY;d03vTB|fNJ!d*0DpyY9$Pzq)640HKy|p}WhozPltZpHmZG(9i$!99(HmGQcbfPL8=yNpfVc5rahWR5KVq1ZmJ= z3QEHnG0m_i!ypdTa}Tb~86jlpjqatuw!6==Ze{#9W_|2Vx@{49RRNK20s0K&E83iECw7md$QG>H*UNTR=quVs*|m*aXF27^^Sc+N zay*@9jR`(M9xmXFU_#mHu(wNUES)meXCb5XQPb$S=lbH7axA5=DxrB*O?@k8W9 zdimX@044hMo`EdL=98*E6_T{$&O$Q1Sy`UIQUYRdxrvWsoRX6F{13Y5lzn3d=Ac2s z*U?*3bgF>IQCj4`Zd=$T)k5ogG|{ajY(Dp;Vs~nJ&b4Tkg7HlF9XRTli~L1a0r=(U zMb|g5BD#n$&n0oxM_D9P>Kw&sLMPlBlNvl6lZDGNE{_ukw2tgDBRee$e=KzJ2v9(Q z4)@WuzvIX4j{{Ikl0+42w%I+kDv{S&&xt~qpHG$fE0h6z-hmcu`s8*?p|<6No)$lUf#*^@U8Q@EIYS~c zgapeqT-)U*7GgWXmwrt?!Y6a?os6=DCCmgN)h|Up4+eQlE59Vj z4-|>l-5XY%D?Tl{7rJ2lPkAN<&W3-5hV5j_znmFGo-ZMyKY|^BdQi1fN*={GME_}DV{h!m6|5rp6lm*%z z({vmBF4r(ibfyL78NH`3trBxlw0h16H@~JnzP8x~v;enx>}Z6b#gZ=(GsXEt619h` z7)37ksx5=i;r+y)Jai{I%KHfdI$oqQg0P99oDM^Z86#awR}u5>9xVm|rV0@7#3zEs zjwLNC{mV80{Ye}tOkofOd<(*lCKc?Qmu?q#Fi`t#*wd!tYlOtl_kc>q!!xyqUDgYU z3%c-1UY{h9CJyASt-{})|6Hq#zIOePxb=av3C7%&{U}puZe2%Y?+Y&+~{sB_;_t|QXCcmXl!V*i}v_sc^g#^Nt%Z6$%xwA0@+^iaQn zl2Y?XeeeSa(bYAn@x5`rDCy`&-_*B+MAXA?< z9QN?H>T0`>xOpeyAhtWtTkRNX(1CoydkRut;Yj1rsy^}@O-5^l_V4CjogIouK^oM< zPd6rj`ZIWERjECXVNC{B9l4}1U@gFmAE5r+Vx$&ECF|OC+_#7`t}t2$Q;oKN2>N>V zlIkJ^+o8Sw0`Oy~p?QGBuio5_<%98q{SV3A@5`~*VzR8hDB&;#at$aDyiuSmc{3+M zm{)`+620V-HtO#SMXGH9cND(yS6b|IguEi14j`I96ogz+-D7IS35LB6c{kkDhu>uk z4epb>_uHG%$QU#~BG4iH>raIZc?4in`)x|0`oH#&FLYiZlmvo8f28;^xv(sw-AH!< ztL}vZ9tTu`P~(CMk^p7_JM3n}>ony;bUHx-Z)NDSe0+wZF=-jvZDO{SQ0TIP;!%e% z`+pFWFk%%cK(p|Qunr@tbeqtaV0K=%6^HT^$UJqxdw&3`-eT2iW1Hwwr;{&~^vP{r z6L^Kt9eBaFmwLcH)NTa}8J+y>w)0knJc~o4Fi3pu`gm%rn5W23QCL91iZ^kFRw`+)Gcf zSpU_Z+#bqxM08L`;Yc<38DhahD$R@ryV17X~}auCgAuWyei4H z#D%BmlNewt4E%Tj#YJqwKrcHrT9py>-}3oJ`p@y^f)<7|^}A|LQOPiJFv-SWt$<_> zmJA!sUen*duOmd>@#@Q2Y%O(6pI@sOosBG(XsbaVK;YvCq92Dv3+Da<7^~{;&U_*o z-Z9k|Ey?e!(3SCCq62CpvzfdaexLV-C+JCF;qQZ+<|ROlJ>fhaH1?U#@7M6z$u3Ov zZ<2F+_%4mDI?79Wwq943JaKiDD;#7wHJ);&azB8n9eSR?FokjEc7cLUhmG4s)F=i@ z_+W6~Z6P-$fW|cm+>*Z_)5)-YvIrII3BjlNf<ZFyULj+kBrGOp87YTxp(s8j6F5T?Mm;Waa>6` z`)6MV0E@%etNw`mI{~mP25y4MRaW0|4(cl~_R6SaYoW{#uR-2~A-94Hwda+mBqn>c z+@o3(*4>MSyJ=S}8YeNFjjMd0c#Bq>{}({(f&fC~rk|uEER-o1K zE9AE-0t_|t=gP7j5*;dmcNT8@SRX|Oyxt?PL^4|a;E?i!A+dO~yjCTmIP`Z|Zk@}o zC`~0RNy_0lL|vHBBm$?SYEe*gv6Jc5#V|H&un*L}5$Omjw}Rj45N#hw`c&EQhs3{g<5II+)9A+ppEioNDp@=^t7> z-g)~Y`OFzR!g-S*Q0Rx=+zdk^F7wNjB zz5;SbV0>tyvkK8dFJFSWd$fIz3mO8Ye}h<3fva5^x!QXNuTB5O(r2kwBgp?cv%E6i z51iD8<1w=)p+{WZ^jXTTS1PN1GUgh8`tgz_@5AsdUDe~gg%jBp-+xk-34Z8pObz%K zzKahd`v|#-+2psKWNElZ?07`9n1H1Hi%_f=Jx}X8cpp{i`_K@8KX(c1XJdvkB=%DV z&00+MhLjRIpO7&XxWM~>Dj*VJqfDK3VvXT{?wdANI+*Z4QKS+Is z)drUDbOntupjOXupsvAe<^sQvc9no=sfJ#pm4F6lOkv_06us67fd<_?s@_R9fFA}M z<#%p6oFXCmNjd49sszk`Xf~`9D9y58yUJ^~u61bvyaxhGrf!ZG5UvB}HNnASpzUsxl&T%~#VMRGl;TA$Er^GRS?agG!`!|0a?0cwP2u(;t=}l*K_q5# zrQoc55fAjQG?{iS*tHK!Um&9OZk;w>uF>lh9(7q!r~v*2WTOeLMoYL#c}UkA8?CvX z2sR(!B`^T<)ViQCAo$m1W=kVCzevB2d@MyL8AH( zJ3y;>!+~*#UE&qRV2lp7gRnglyo%6eK|hR2l)`lDqm>k%Rmj?ai3wC9#6pE04QqDg zbDcgD1jrV6zdkK!!t2~T5}zqVFOQBv*rKYV;D?xS8h=53{lM}ok^uZp2bCVu#X;Z( zF%2Q7#)N|&jyhD_b4i|y(#^yE2HpjrOt_GMerNVKQh_Ni3JC5>yCi?*Fcmb5JAIbq zYn*Y`;pmodtzgnsRuJ~nIAY=!c_f6f*uQxEGRWGz(6Y9`rIi+=tl3ClSk5E&Hb~{) zj_)AcWo1@o;({Fl4g+N(|05Iw9WnS@>^3KQBZ>Q-ein*dta69W)>69T=ZE9=!kx=Y zHO?meyC5+m8{C-$B-IJY8(8U%O3bg25oaCw^B?4Fy&wHG0L42KQKumFBg#j6EwDp= zGijbG;QPkT%e@%}UA(`qKr!}=8;%@`B6X*6Ju64yma`1iTltkvPO^5VbN*ppYVu#a zzk=Wgod_(a%_VhVnzY91dpC5f>|c4HpWVeL0z+C2QOy;M2LjPt)&GU0dKz#M%Fh6z zt}H4ga=3|UFz-{($FCOeSi8u3m)x;WXkrKF{!a@)-np)XRUPf{!tIopWM7B&cY8D= z((vUlR9CF)?J~eMDP*de&j^C5eS_)^fl}R;0VnOCPHn#3=FZ>4s^Ne$Z}hG}vUUNh zC!;Q$w(xpT1Wgp7;Mj?15Fag}Fwr{7jJ4^vsK6xA5ccxXOk7aaw6!U1>ctEs-`{xn zwkG%2-zkl8ujnti0=N6m|D$pJr}Ufd;Fp&hq1~+4!QTpGA3rLQiWV7PH2V2dIgrV- zt}@$f-3F5+wv!!fki220zt(c>_j4W!^RH=%q~cA&nTij8#W6P}`0>6Z{pup4m6 z!-(=>-hlsTxK=W(-87D4P0nd6TOtuCT>G%hZZ_hAdRB>jjGOvXc>;h=7mjH2Bbz7+ z>Bwj~YS3zB2_6+vRTIr;E7o=sR2?fe4Pt7X=jYcbHMl)@&$>trtNqSf|L;A)DGajx zc)}9T0RQ1qk2Mx?It;a&~LWgxJz4R4|F$CE+)F7Ep z0;d}~9Wqj3Rz>Le=gmhUwkGIEF|KzgQLX;9J%U}WPk+lZOgQ`VZ zN&O!Ri91RklswgEx4pC-W`C!|O~}_!Z2SQ=sA&mMHPO5=f##q?H|Cl7ai`A8sf=6t z%NeN}5@+~CGLpx)0uS$M=+j&Eapts2?kZ3m(6;<3-@iB8KfRBB^R;6WEr&+%XZYW0 zpYhykJPDV>qmgt^!k3-nCz9OE_#sW956j#qmC@87iI5qN&Zkw{I{hyL8JpmeFBK0L ztd(ANmoESz)3EVtFM)4X1Bta>p^rj3G<}A6)aLO12XR&Rddi`Y%*Tei{H7wU(0t6*Hg^UPCQT`|IZUSRc=^<h3*X|#eTot>3=%K ziP)P|WtdquLnb-$_eoVa`S~1*@dHp{U@F9c(pN%&bVv^UqPpDz(j#J$6%%ZuOe{>q zR}$lt7op+z*Iv)|WhUKs@KdHaowx6>Q*_CEE%L>_{_ML@5o2bWJzdvTP#c~b_%KW; zcnNPv!Xil0&BcUeYE0o^MjPw4JIY5AGNFXt^|z=R%7mfbiBg0czvA&}Z#{l@Jqmq% z*`Is?v_sWt31jqOD<9PWKK|eK-3ZT9Qb5xeBP-41T9NJj(D)5w4a>fWH0o@OcF8tv zO_bBagq&XktexBZW8ns9TaBXg$592GwEbg@Q2v;%b5Du6@d_O&S|g}C>W*(LO9$Nw zT(17-@S`(L!&q_9cNDS!u3qBuI*7uIW975_%eOgO^RrH9`!zZp&}9J-<%TTZKa8hz zZlPP;x2Ed}2tC>&=}xb9IwRkc4Z1Fm=^4AgQ@s}ctvnkvX#d5!kl$dO*7z7q87S}n zSiny2Y?{FKL{;U_=({YxElW@jOSQ0$cCF}5? zWjcL6THxs4?aU&0KY4tP5?jBmr%7hDJD@sVdkuZ)27BB}b~0c*5>n3Ge@&_XTSW)X zTWM(i84fZssLi@BxJd@n;hsN?MqqkOf!7`el-nUtXNi)M%ox6e)Ssd8+V!`Rs{v?( zHTt$@kK~k@={Iz1)3_krlug>Sj|&^MZR1|W-3yOB2Y7Uy4MdNb7kZZ;&wO0a;Q94o z2H*yiBZ0Z*5@azo(pZ)UZIp}$%ikrthYgeteY~zT@*3ADVLVfW4sZh}s}Ft_1)+gH zd|p2WNk3&iJSXytl0@16yJdZ@FE)eJ`mOTs$UJ;f#jjQVj)k9u^NU~}nBZ(zP)Yl^ zoyUk8KXgSW<;SIkoY>SWmyHd(WNyd16Fnxgg69Hw zjSOJQ96w#e8JZ!m&<^$b@M6*!jIqG*P9^TQz8@RdJ)IJDsRA%v_Fsh7F3gVeUX0gD z`6S5YeV913A9GhIFii`NZ(fdUOl2{%S_$oXi(O)7KxUsFapmCyEPo)t^AvY_Ln*IM zC-BD?A6futK82S-`qpwhgpm^Zpt?e)1F6!%@s6pvxo5bb*Ybd>fUi(LCTgm`vvo&1 z26y_2W({^~7st0)3Pfst`}t< zXdbqb-zE}#ha8e8g0&y^h#2K7fklFVvt2+s5rct}wk4JYizkGbrV>k~KbnRddcGpk{ofkRe>lrM=*a^4X5{ySpo-f2%_H?M zMt??BGQ^9bTfxFY3Vs>WEI0|?{@39>VyXzm?whI-5ir|q7+@Iam$<{ zZ7p&Au{XU(!pnD!uSk_@TqtOH;e6IoS z%+J@27QdO~I%*eOQZzK6OCd-VIEaYm|ChpM{)w9qFU$jus8G{hG-e(Fdsh&j6kPt1 ze#42k*MO=fHUd4hKl|Uc%q)}p@19&%22whv^2pwRIJo7$D!Ft!E?3}K+N-9|k?Vk4 zS?4o?*yb&JRoySw9mm04UdgAh8-*8thZo<(s|rSZ0_~@q-WL|Zno$1xM&^HpU@?y3T(NZ*)Su?}dQYSVYQ zCy1SDmRQC3JJc@E&Av7K3jMLP`MdVIkHqP0P3%`kPi0!c*PRawPJr&k6e#Es2EN<4 zj>+(n2BVY*20MH*RsO=4D z_1~~@1pT|TjgYIHURaPu&pE5u=?~pN+&mnGMHz^tS%K!50?Jb}M{N_uN(AXainv~WyLLsEsq@?^0X6TfEG z!?Xs=>YgnwsK4jbf1x*k}o*Xa7 z@+P#opNJhS-Hb`-&vpAuK5IVfhr!)%qZUO6u~5P81QT;$1!u@ZN?|#b)`m1=_4ys@ zU0Ivn&{g_AaU>)!k_!!5akMl*-R;e*)r>6_HSkUde`#g9sT#Na`p%YZVE;@}vM_aR zh;QY{S!6P%Io-M8efMni)=iQ$Xq-t8jq=*A2=2vivWq`Im_gH;e9YIk!zo~*;%^Ho zOTqoSsldFBljbPzCN35ttZZ18Ku`t+jdhEDq(*3GxMT9#QWyx(hSZc3KnqU8@Z1bwp9+8ma5pSoa1f{&|yQ{2io z2g!t5#B?nCDRa4V54{G*M-&0wek-3};~zPGR!&bvTmEjnQ#EnStX6OF3YYY{MI1@x zVHkF{tmzw5#@3}~_aT%BpVs*~`Y}f`KJ*_g_=p<$prh2+JYE`B^r+9VEs)RQeff(M z$E9fTW!ne;jxf$23-4MrQ07$029&=>YJ!43cMp8;j>_~uXu zyl?*yNJWrVHhVQcyvHAS29X$H3Rgfx(%T;Hf66etW#Pm;Hl(Q#gwi<_rn~wzunKJm z!X(mTu&gr8IK(knb`mO4hFLYtDi;QCz{_ z=Cv7Rl9k7`?~6x&MTA18oEFXPHJyoxpiY=zM-YDiQ+J}}GWqTOIGm~(1T z$64mLHv{$>+zIXJWw20jTb+{pd46XUYMu6UJJy5opGm8?!;hJaNSJ;fnwDmWPa@0g zQYaSGXcxPSCDn)dNz&pNmrB|5Ci!0d(Joro{P*+MgbYrq0)|gL9Dz=GDRgl}uyB+( z3}Q#=(n0m!-F4S6L*B=!i5E{+OJPNyDr6*uonj}j9Qsv#h|flyKAR9G-QO8Z?2dVC z33)MsuG;vX-CMoPLwPFSzc#gFUeToHy8o>^JBWd_gQ$ben(_xzKWwg!w1K>}Wm}^o z+Eno!LXo1NR1b-S0p=vx2xrci>c@}KR)D1JAlMS4kersr3|v7!*;$_J(e z(pG1V91j?0!x?F%fQCYEe5AP6*pb+T5WBvR{S6c);UBE7$m84%?g^cNW`+Z<>m}qr-`&CI(M;S%J z?iM*M9cTt`@LZ{Vj-!FLz0yZ4wXaD%i)t4E6^0rI=ih0UT2<05Y*hPRo0I&Ok}@WG z#(K5^3o9dGXi#xgS6>;!780`@e_4B$Bc7c23kJa@z(=S1iiiGfH(o@3i$`xi zBVEDuf1A#BJ<=v9E0&Xgn z3Nf8s=VTm0!X6}U{(A;2GLx_63vHo<0s+sEv7J@RTGh`PF7Z5<=M7o4n7T&Z)%cOQ z`qtoSGbFo4vY$E~GLy`Tc3i<4(YLFI@=^o7)NFA=jMoqUO54Apj>A+px8l}9ltsIC`)MVSb{BCh)Tyxx-sJLOEgg?OXUpONWlZAAdyl>ay z)@mYBWk6z~U~!&P1^*luxS;*(#+zx%VOY5``(xkLUiPTpO{-gO%v5oD2NJ?BD%#&j zo$Jkl|8fd!LT++R03z2WwM$3wtrq_a#yeiMpjc*`rAF>1roJVJjy_Pl(rFq_^wGSkV$&-##{e}Obwk=+@Q?jrc zxdOrDTi}N4?X0?dFj=V`+@Ays2|#>oLx2RAmW}q_(65s$IYFvF9Gl81i-OH9zT3ca zm`Pu{7338{sAVup*E0J*;S_`Mqlt*1wSmA_uPZg$vpOBE0nUNTS@k3mfwIo?4WjB( zFUrH+s?AHo@)@TEupo)4?D!SV;l0fot$|}C^))o6(O7ZLsk4d>2PXX(%cU&xqKMCb z2D&&2+da}1?ZYu3x>7*IF3;IsKVerN$%ac#f9*~MrlMSwkBJU}QPruu-^on_M>a-% zzxjBb*X_Bkl~+mMryEU+#8Bt3%!|t`vW36SOnnX($W)99J&E5jdN}qfuL2*aD_I%r zfbdh)_mow^Gy#Nx5e7P3ggHUoz(HhKQchOYWj^LrcM1N@`_|g}p+A<&=(1^{=kv2E zTQ^w&h;}=i($N8BO)jSS!Vc8WkejOCqqZ*Adw*YDd@~50D^;NGcXehb@mb#$(|g<^ zggdX*TW}SPFsS^uPgU0E4$S8t7SH#-eOy+AQbV;vUmR32FQ$grmc27LtlVbq6p@6K zWY{lNp^|Wt9@ZjBdJ$?^4HLNAFusZ|k~%uKwE4_5JiE>MpWtOVHZ+4_YVgNE;NgKk zUKg%=c$Fg|7ye!8yAzL!HWF<=pfy>p^cbnBhF3p7{iSod&v~IqlC+v%H@R#fak!YL z)C=UYkKA5}dRL{-n23h73u8oSgta(qKiL)~z3MmbT~;(9MDJ28%j|2OJJyMftbnUU zd@ckw$P}us8*gNuc%yoqhqh-rU7RP>tMhrBm1~Jghm!5lP@H5~3ynhHh-Dm=y?6_e z(Bbk(sSJvF*Z0+m9AJK2sv`wI3lSq7E;53&UrELqUQNPT_dREA;tP*VIoZu@opuwO z0}(Z!XW#c!zN8vc+JKK{k(|<;#OQF7>7LB4vd(1kD`6$Q(LAZfTK@}!CP;AHLhO(|zCO z4l(KdIu4aNNenW>2){Xx9`YSNqw%TiOQK?{k8-YUjqf!}AD;0~3*s21`{Gu0h0*gE zZORPLB&+3Qr+IqTCW1!SnI3qR=DD8B{5{cJN}=7~GZim5S)8xeKIs8xvQ}XfrVVgb z@#0&G6h(g4$t|4KdfCNphqJ`zDU>Pnf%#>VC}J$+Bf_GE@s~~>C4opBbp!_F7}%G{ zR)xWXe_;r8I~8fBE^3SqmwMR?u7M1q^6auw=lEMBka@2A2XnsJV;!T{FmZuh7umXQ zW-2X#>*>w9P(frikuuWT{n!zwOD*j_d9P?&WT5OS`MxO(_;QcenW~16NPA;~{cc$) zSNJdOqku|f9nm}57*m`J-Xz6v^{j#yGIaHBsN1tAavyNYM9pS;kP{?Gm)RAPNl#%3 zcl%&B15r>SAD$hai|I~GMy{BD8Vh&RF@&><(Gm2Aet-}nW%gMudB+`5{U~HkD%}J{@sr z2pnZF>rB_6wjEoDvMB$@K%_Wu?yuo0o>Ry`9lE6f`gt9N9lcBP-LIR9l~_rG04z$bm|fpZIwmiNl<@#)z;FdF*T8 zJL)RH${()APde<<8iHxGm{>N#@oSRb`F6}_CJ$Rf18f-adn*E7rp&d#zv_tz%#=~- zjjJJJN%7|)qo(xhTUtzvZHCmvJuNO95bir;T*E)vz~gd4d%=7N7^-ZCfvbzCi*VSSjPP_W<@s80kv0ZSU(lajI;y`5@QAjOTGG~rXT zGW#!$Cd`$LD3eMWLhVlN3dM~4V8JqRv_VtV8oRWHvHyES5KZKEkUo(31W#Kt_pz#9 zGQ6ALR6zbNCy(tEccv26fc4(ssgrX`ytiyvANEiRM{ZGrY_^$u6g5z{TX{9OgAq&{ z+B313_nrdr;iNCuw+h4U<0OMka?Zo|Tt#C=MTqavp46Tqvu`8+-bGsZ%9uSbP~fEz zmoL8gS?lhX_2u{T(}V`mwV=7KXEKYb5txMr65FDm3_D!_vVq71jXBnTIWi@f_aB$L zZ>5M3_59Qx8m6ap@Z_WhUY4%_`HWcWi$#q+YEk~ECk8C&G8kU5?vlyc2rW0xrVF z9kP8goUp&}u(F%;8&)8sc&+BkO7pOBxkz@MD7sLle#C1qq_A0b4*wd)f(9HifE2C6 z^~JX4cE-6bx$?Gc)oOp3^AoRZT+EiJ2(TK7b(Zv??b|3<%OO+f*090DqM-Rp$8J1( zqcD!SGNrZOJiXjD^Ow2q*;{WVzK(0dwOIZBbx(qckLje5qo=*s`HHKQ{Dp8i^tO2n zOW_id?-Y|iN@G0XrViniz07PcFR%`wKpbd}07)De8tuLv6{TL|io)FyG~aoyH=u@0 zIAF|?9)?({D;Sf_dPCQpqQ?g|JGL46uJgWOg_szDK42E0V)OMv}!JE@k$Jb zS6z_9++MfP-w*<)_8+XIKqwO-C|UfTt3JE5 zYkBlJCLeUzMK>zCb!ERxL(9_(1?mj8sbJU&$D=|va zl8}(BNe&T&cPh(^Q6Nt}y7xRPsK8Haph|ShH>2p^-3AGecY%y_<~xpQkNu^07@bRt z5E$_}doQNvZM{NxY0Qg;J=nI{*CT(8qA z)o8mbsRM$Ripx9Vlz8qVd^o!kxI^m;(`>Ch z82z$~vba*nFrEX}1b=%dl}MrKxZh>)!Y`|+;~)1gvgJW;wyl;l#x(+5(C6{$!5k8t z*~gVvZKX)7U(O@xnU2d2-{j`0XY0}|V^`A5Cu#TMRmYN2-TP%lEfppf7#V#|I|}l_ zZ?j{xd7`ReKU~L z&1uJH#a#tSo|_P4=G69;Fp;u!tFf!)7O2m>*&%muwT%K(qhR>({~qpp9hP#si93Ce z_8Zet7OMF}0X?CNVBe`E++!_LDzEedo80hQnP5;wSV@A4J5153vdyc^AqHQQ ziT3)?un5xwH(c5n7k%zfEe142Xf8ZL>Zu)`3Jptg5z`bk2=6Yph;|bd12Rr!M%8-F zxX&QLKP9=0J2V+Tmz!9}CxY7iSFdB! z7D#}{oG9mh=P1FM-TN6a!S6hgy+D+&(=p-fkJ9X8^Y;+{Px6P6kh@^2u7n=5)chP4 zQr>q~I;r^(KOSmu0sWUMrpfq$pnBUexoL5*#F8JzMj`HID?8jw8_l(W(}Iwg*L$f$ zqw+mZCn=Oa1e9l2f&!|E*?>42CpTrEJef-*P~@ZFo;?ulJ-Gn5zdC0L zjqS9H$c$3CazD(ZZ>eRyY%HM6}s7FIt| zfC$DJEQSRCE6tue~|Fj^xCk}Ay_{Io!Qf|xXMrX?9yQB z^BvXa&NH>a&6(oF^7<*QFG5>UM(lDv*a_4!{&!pWbSvabv)diMrZJyoCa(1u^uMZB zumimvCkqtQUi;VjmL~TQUfGO$#BXd=o56kWo(l2ozlSh(=|iDN5aOrnNC8|9xLy! zdC}u>N$)bE;L6sik64P{=ECLF3OcI9Fm)n#Z0Eo(3^Ekgnb!^$G3RM*uRxOxa!XRs z06IqX%uC`KhPiJ|ZpT(DxMP{Ej-$@spREh-X0DIOcdJRGhbHW-kcfXOJhg5L`d0Wp zKN=3F)JlNsgNcu)*j#mI!$ji+0l9F2qd$6b62Kr`%9i-ZF2YyF(5sS zGwi%$G#>`?BMNueQA*?gqvBwUjWkFK zNJ%%+AuEVF{Z?i)TI zI)CnMb48vhQsy3p;Df?Tpr`pP=gn8xk2E`(h`jA~X{W`^Ug`?=FbbX1t#QQGd-B`i z8)Sd_Z5`ElOI47}AOiie$gBTcv)$2@-w6&c%3Q9qQEv!!w13cxec5a6$Tzf>KJFkY zS2&f&vOEGnMEnhkrN0sP18>8kS2ea$VXp#&%pCmO2~{#1hdd3eEJX{mjjHSg3?8T* zK>pZkXM>9%5&^QW`z=rkf|1#fX@7|3rC8G9OiLx^1cNL?bV|s#TObJQ7 z33JeWZD)?(KH&Quy{&WO8b71br&JZ_^r%Yts)aXBJG7Zb(pTt_lO)IW{>@G+^Zq$K zR+Sw9k9)Z?kR4Z6IJVt$(o*$+jwR+L>gV4a+ zc8pwq-{~sVGSg9ezwLgY7k4K@K`bddBvoW=YUJabarhdXo`ZNWaRi8D_EQ}|>~}ry zKuy5>6I7EhARI7&9L0dPHYTAJStv(8ndY;~UWz$)^2fxw_rpb}PCb6cCtL_~VV7>s zDMZ8jS5iSl-~3ewFDBL7bu$kYQ4JC&xwb{pFh&lvTPCC9z57_j;Rv5YK#a8N?GM>V zcHK|g!HK_AVu-mU0KBAs5FiodINu`sdo|*zaZHi+4GPv^camyB=4e${x|U_et01b4sb!s2H4k8&}=*WMW%h?*QwUXl6QbEW0)(_~$3sj+Ln z7Ig6>%8BW!Ywv4F-@SJ65DfUc!SUf4jK#zKq{a^^#P{Z)96>c7BN376(Nj-Esg63wJ9$}o{46YV|m8DHj`dPU~W9MasPH68ZRz9Dntrh!JO z-S0?plx8I<|DN8f5y3~|vxQJks!rD%6lo~4guDqtA6pcvw^a7nryG+!wxiSe(ztU} zeS{%_yiW#Xs=kX%fTBj^6Hi}Wh)oLcw%-33Eog!P84&^|J72%!l)C@R?F+%9sv7vA zEnm8&k^R=O-4UJU_LtUr4_)fF9be{%)0khRq)pREIsUtSRdk>GEi67oJv(zh^2rbX zRH`F%k65a5H1t6bTvIvx=ARlFkan0M9;vdG+0QPv#_09(&`3E@Vr}&a#+nOImxKKj zV48Ci0K{8f%2W8h>= zhsFhiAtr-GUp=?gbvEjhvCIS5xp!h&rW2txwXW2K%u6ov^957JL4A@nZg9d1Ir7cj3&34Ic~M(G#;y=c{auJmo=a#A5EBofZ)GP5>B1# zb!pZcF_=5NF|Qf#C1xc0Muvb*d$s^G+LQTJ1?Jx!5t~;wc)JfYa8j4?E-26Q&U({^ z>ud?vC)T7!&v$_L@p8-^z7q$2xh{ap1@Fo8?Bs>4Acm^^8$HwzhJ~4X05p`@s;X3m zRGB*#M<(QEBiBDvl#C2ASG-N=B~UvxY!Y}}_&kBM4zl+G*AJXUUaUc4((yVr(aYKj zPHl5AGgz%!;O^ln(ArJ5vbGTL4k$OhwJ)slHi74XB*EymCT6~; zbpp<66RrVK(A1sXSeGRx-%ehZkm6A>Lp0pDm zi~c*`&Aw4uEXJbP0tTEPA8`Dn(fb^!|8q%#~<~$PyQws9a|CGl2*UIE@{cs6UOs zBf}qdNH!Uql1B0mMrlE9Zfh|L=ct5xtdw-L8<@KR%p)*#pU{vEY~od`<7Kstz6ZF~?jf$+i42N>WC zmXnLgcx~p;`AFfRA5DQ!a(&CK*;?%boH{4ZP;O?{tS8aqyh~Vc@`a*2w8E>yiR#bO zLu)56*C6tB(!8HM@L7~v)i*21fhUMdXhJ_aS$jmEcV6Ok=ST-abDqW4Ux5Wm>o~>v{DVreg1Wr+oIf#Wr`%K& zn&8f%hH^}P`6u`iv=1XcvWruKp+`Z6%q%z!fhsZkjo4qJE(bP-y9l)&;A-1jJ6iT@ z2>@eSK_g%HBQjYCL_WH0h3QqjS*^C!ZA@0HGxws&$?fPxFrj%P>GO?^Ra5ysTy8RQ ztbZwuM%j`ujS35dV$hFqtq+WBOeVBx^{e7=+@(}MR1{vO33ZMV9L%RpffapRLXcgd zF{N|!zp_9~mCiiU=1vPBpk@(kQtPcJW8az$LZR2T#7Iw=pO)&R=iKaVMyPWe(etQ5 zfhN>R2;3%|fxqnXNZIyng~M;95um3|<^=wS_G4&oUtqLV%A-5HNQOYY!Qn&Rvh<&w z#&xkVZVvM;>=%~{V48;bsBB|xHQ8A3@s!^QlHgMr5@>MtigtSlGx$J%W+*LxDq}n- zivGweJ;GaGJBp4dhBVB2Resf058{w;LV_ zW8h?AdHzBgPK}%K>6G-*?QiMev3U?1G4JwO8wxa9|B)7$_(=7u*6!9@acDesVAmqg z8}CyqA)l4-1l6x(=!YGxa_@+pNruy%KA~pTZI&>Gur4nMF%o2eIAmGBXOBe{(ZT{y zeX|@mO-sPuh@48Q1JiVfX{{t{kzw=oc!+<9X!i*X+fEF7QS~Rj-o!FiL!UGv3 z23A$LtmreqDrGgl=i9kgU8Sx?8+G}t@0`tHi`htho5y!c$Ez4ZQ`Kmed1?hxTrO@I z!keh{O}#>IhwgkE8_`Op%ZqD)0OHuZXX4t5pA+^+hr7Bq*Ck5L-eAEABh?M^zRaaz zAUrbCeb7v4(~hysh3al7(c;CR{i`A#2m#-*GZ~pydm`@|DvFB3Aq8K08 zgsJjOt=4~^y*D{{;5_kMFMD7ABAn^T)9H`zlI#0%Q6B@OyzvzA^v$m|_Okk3j*swa zNP?hqaVY#S+Q4J6L%(qpN1*Z$D7i9xDhD}P#;e$xHZ}XFB?$Z>28ciN<@Djfy!Um#fb=K~{OU0Rkp(*?Hn# z63#&aAiLpq8u-K$&_F%zoQ=VG__t&|5~A_8G5ty*dO3`Mtx=45b8Z zX3KfV@hPitVzHSGII1GWSD$LJAKYA?KNeR7=hNS(wFGV(AV!-KURf~-i z$NJg`bizw(E`j9TPgzwT%v7yU;}WaVeWT+8-LB$z(g-ZHOA;FU~#s1sddn+$i=ZxYC44n z^(!{h>^|fN zv*m`ja6468Y~QCrZ@8#- z448N6;36?a%T#<`gn+&$hrr>AyL?moV1>O`?C}$AeH!(i!rp#LTct zla_aLm%+ozx-S=$HFzpstlkQ*t(TGpixnXZ<=b^C0wZn?vmhrba$Z#yr}LWX#sZO3 z_o06fB&agV?Ra)u;AW05ZCtR=YO>6QMM@6^*C29U>Ie{y#=vNmLYOT}o z+OP<*%#EtD)cRDy>}Z8Gehk`0U?FbCLtV~twCHJOXZxP-8u0?oIl^|i6j<@ypMBkD zJA1r?lK-lnul+^#){N2J-F<3~^74?*=afQMzys{s3SZ%2@jSd2U%I5T4;x0>0XBLH z{V#V`W&IEz$dJDm0=rt)9!wi5F<0;Z`_<;b@N;7YYO2v}W^MZJXQ_$H-&GLb2UtH8 zkRSg$&n=gIM^q0%lDe7BKjp*=&k#U;8AVX+e8Eu8XY8t=6SqxbgmAvBU*~t3z~`U= ziWh&*|LT5iwhBu(b}N~9G4XpZM>W9#Rea#t1N#{X*Dg(yxD8j`;#q!oUOf4VI#0U% zSLg<&F^#q3uMr~{@X2WrJPw2H=U9`}AQwK`jXm*upwcoUEv!a$2|KJ@hl>a=O8Mf* zwkM7~0~_|E!yv#^#90RX^{E8qR=^06`uM~7n1Hj3I7OY;MT z&Gl?wRr(iBr?}sJ-EE|ut%3ty=g*9~b^c;ir3cpy%4y;n%$hw{!3#?zf01u5-$AGg zgaGtEo<70D!?3_R@%ADG`Tt23O=cP<*gcRHQ=%ApqMfAPcR!sf$^j^c=M(I|_(lEB za80;O^30mE(>=Q!n}kQ8ti(X3eiwIzE-3Z#X7T#~6kzjmb_TvL89sR-BY_%OD^0P(gf|MOSZeiPTd-@>x;}JoWyV^h9|FW? zgjl1UG-Z6Y)&6uqbpon5VWJ8txh2{6b>>3F?4x)~K?Nm80Xd|ZxO~A<{@XFqG$^tw;EjT+ldFmbO9r|%Rr|MucHCPJ zw=s(|E|S7NG(eItJ%A|`R@?htc|9t%kHL7A+Yezz zuaZQweyHCh&&-yQd1$tY(j6K-2pBtmj|(g=tVvD3%@*rp4GCM= zc?)Se)D?67q8ylFj{YWkLO9ayk@fLkPGMZ8eflr?zz@*TaZ=*s)8MCA`+AzVKpGv$ zLOjN!6RfPewcyCeb4fe-W0D|<9i&i7O>k$1!0KfmJ~%U;oq1U zdH1n0vul(ECot>WmPBMwbwSL&QEp4zJ*l9ofdS-mNFz`Ney=GXRjcIbi)=bDmY@u& zXiNg%71=V}Q)u(V939Wnt^BZ2#G)nj=WMS#F*`ScPjG+s=67EX05hF5m%{KJ*4ra8SI% z0~X!*SQ7nO7%@WFWjNCX?I*IxWg1|K?c$)uSwMRB!(qEN2LS+~_bl)5J}J(;VacKe zll-85+VGKa2p*???Dw0Q_ogE3n+GEy7`Y(et!51W@A=`#e4nU1g5J!faN391h@uu9 z$&KO|LwIxg;{jK|%}WJ_^-5$KaLz45qI zX!!GOzG3a8{Fjknt14S@c$+fQ_`|$}Gsi#NJC91SYs{4oRx7K)ikvn*5c z%JQsIB`HQIAqoP(`|sa&w^WK)W}fB3vssaW%j{K zPeCmIUGukLhJ`Bb?-US)cbv;FoW96+d@D;}}c zSX!s;hM`}CI8`je5hgGo^`3Ur20Y@LkAH_!x!GMiuxi{Pa}twe%f5Z>ebncb&&^_k zc8-6VT9Pj+&R**ec-|7ke;kuCt5^5|xRgT!SRQ<~yrFq4#ZQGHpU6iy`|SoJOaM0J z6`5ah;}la^K$dLqmBZjkkP#Iwkfd%!rLRR}lbamS{|Llh4P}wS*TC6l`DW7+p|5aIiGM2gxkzb4*w_^7n5XPGQ zI3aySiXqXM_{!|fzY@j%e_d~(K!*PoMTqag2w&4_^ax-O+9u_-+hnF*`{3!?Ky$OQ zYG6Y=Q94Xo_yPKJ{K5e&zlJa9%%fZ79eggI3|&-l^DH z*InbltBXKRMt#p#S;Kt6mZa3unY+1dPJNu0K?_$D$6^ObXc!bXLnGJ&ye!5bT)5`+ z1&6m!>6spfJVMgy-H#!3GLmk|&qbNVlGgO*3|FX{MfMs-D?mHAsX|U<3fC_%Au>Cwtgcg#<+&xqgAWywbz-_^KHkvmY=^h`DY_%YR@l=!?Jwfru*}qs^X< z>%SFGC08(@$Txj6?F82zYJD};1?ZVJqHA}C;G0{*^r6)e1ITC`&cTpbsVJeC?+U)v zc4q+eeamxJDtix3-tMttRF!SZrwgYx zj_I@KG1jaF!$!>M??^4~MhF5bwVA!UF8}!mkw#cshzDYKKT;b?bXZ8p zz|jOeW1icH*E~Dcy`K8*wc2Yk-8ojmiZeRpC#Ap&1x~=rzfoh2^^^9$&R{^&%vT>e zg?Q?hWTaMf0^HKB3qS~cbiZnp6WtA~Vx!@z-C1nL0{#kVq%S7$+5jv7 zNX~qDVYDDESu=JR=t-cqt8|(_P=>1pNm0V>rPRXJ7rkX*)~oCOh4?4@L1k)*Czdeb zz+`n4;zkaMCI`*`8r)k=QW;8lSEH+!|CDAdZJD3EEc;8qjL)9Sf&uA#vt z-4H^CLy*jHct-EIlJ{@K!Ne=!9^=*D1b_k{v-9}iDwfBoFdbN(PbAY%KhgBlIe zSQ$%$fWa=NST(YaW&5nJ7fAWt*iOVDqEAPGoB?4Zz%gN zQ1uZvBZmpsVE*YClh5fVKbHYqauSIJt|k5D-PWZ|Y@eTKf%tNUEgSje3><)$q?Cvy zq2whK{wer)xE=%W`iaWRO+%W$uGygHP3Tt9_u7Z*XAWa5YwmN51Q$R@7MN^nc0m=o z6cKW#f~gZLF)#xK1OS6S*JsI5$y-Uh01rQ2LIAIH>2S}c;@~MFKbS?H&gz#jx93+T zfEdvok+zN=pbgKip6Ar8+BCdkH28pvOnGV)kBN*w+DINSpM0#mFZc?i_vI#@gFW9P zu7Yws+5T=$)?fmTQmbe)run>?E{_KSxn=KOX*k1Lo4c9W;vACExbHR__BDsSZT90( zUS2Kb#w+zr+hg&gL03e4y;x0KFLzF7OSI zg~An2>}@bprTEL3tLh6<|VhFRU6$+U@wMY%Pnp(uQk|= zYQ1Iehv_v%xb#jbYVvYyje1lVA57D~*hnK&Z#EH-;2Miz{;7E7@Z^|CR^mo* z;oBA(BOq|^V8!Y%Cvq3bcM=H~Ku8Ug3|@Zp5y3{hxa^|oFej4(Y^*f6B1vrOzpZXQ zwSlnjSS`0ZqR^bS2nF;@<;7X#;kXC*7c%ocW?K3W-Xhr#xcsCUJ>Ghjm zC1V7XhTi^`eOAL6u0f9Gv%l)NPS>ozo&9xn+FZddS&O$XXGooX*!@@z((AJE`UZDj zq3FHdZ;GD0sV^PesU^PaLLNhWlGQ~@II?YpeU|wcaB!FV6lCs; zS^Imj6I1BTwvSPud{_QC3+a2<+*6cP%Mn-zI`FI`NE6KKm$)~Y;2Cc6_}?y0dpN~4 zXWlCq2@8*afd5K+`3kK&0z7^YPuN|CP@9}V9M2~>b#nSfCPx0P3LhKbJM2B6-_LL3 zC2scSMSh4v+7oDalK1}`;c@CeD;V4|K%{#Ew1Dkhji1FGB+3k8zUckV9hWPH*U$|x$^IP;jW z3)8?X%<-;42w=GkdN`+D0lR+AOEy`~F#LjUH)SssHFVvwR%GI2 zek-;sBv_>lqbeWA6FFV{sUD-2SreX5#e)4#y%YU+Hw-K+xGy^H5vEkA`pYs5num+k%J;Zx@2RQPHW z=pGDc$_YVl)J${#Ym&@(aemWsGLnL2$YU0H+8;PW?r+X0C)X~5dzZepOZmM+wJ0S9 z;lKbAa>9kB^L5LKXZj$=xpn8Mjnww|wEHjZvy0bulFoSR&&G2@XSDl4>Ln2dZjk$aUj}#s6X{!s;dg%o zGYt^3f`sG`S2gGo((`pQnL~^lbV{^s_FHG9thVprZi+sM?nfS_WCMwZRNDOzMJ&_v| zBf$xv2S&lBx!GTjs*g3FTK;|z`mMH|XnXnWAz0AoYXo17UROg-uqXvh!TSv+ADW@| zXRSLoSWWQc0@{v<6=#Y7bPI002le%#qmKwih@%7TVw#^HTYJf{|0^x*Vl(7K6& z^$npKpf3nsDumjyDj{zw7xS8jWoc3CJ`xr*BWW+__9@Pc42?Oev8*QL8>Z2)jBqnY9eEk*oG`Oer#qcL&q*G4n=@Wq4vK6Q#* zk@Bh$x3e_3s=CK(;Jxqu z4_*Yl7)rb|qZI?_m+v?ymsO;5wHpbAfJY~vWOcvqTTJw4gSX-6uiu~_b0iVRT$4aK zB+QjDjIv)!`QED|&Z4x=`_c1-QMGVPGm40>g>2*UnM>to{%pTMm?I$V*_e999x=?x z{B3@6%fUxM5ZFNDV%tqwGPrDGVq}2*7SvI64oIO7s%u@gr^dpTeC6>Uap4$hkCeSv zpK-iD1TF0!l73L@!R7Ue#$?MpS(L1izbkm1 zfK*Yt(A?X#&m*?67x&n@=7@~MJR@%~Zc|Gku$p9B<+mk1N2@=^mf8Gy@iFu~kLb?+ zJuFlgS9}UFb;1~Y<@mzzx%Jw9+je4Uvhg48@KjkOHx!`{S{+~fO6h(!v(`lVL@tGp z*5yEV+jIliXvc!f_6D85-7oCNQK3$mYYMw4mo#FDG_7(K98Jn!u3AVJe-A*x`}Ba2XI&(R zPwKzZU2IdH9=m`9N)9NO$b-({~NDpktKw!-j$;kfRGd3+xda`6YqTdCJzqzDYYAlZ316_Q^tRNzIDp=?*)?VfO7q#w%V&a~hpe{k^W-b{ zGmb4e5ckRJA2_iS?01ywA1rpw9W1eue6w|r-Q5>lw4m9av^)S4(U&t5=9TZ#adlRx z+4M@!AIPYz9t$~RyRrR&a!n`H%c8W^yjRJZG}1>pXMWeC0X9EZS_3Jur|5owlW>GGx&zM{xGPlehT((U9am6wv*;B5qMl}%q7}_f-Dq*; zG2QaI@uG6UIrSy@=Ua;wz+5L^{ zb@7qNyx*&TRAW8uUitfn#ii zB{B_xTT$_KS;0Jet87Fg(tmX?b~DSYzh+mT~o3cGOPB!=q32rSU-`>$m)1E%j&uvSGPlw{bp`sb2iazVYeo z>!yM~4ht(uLkmse7uO>z+kR)14u8=!7vDdjOm(FH`#|$BM28SyP`h>LAYM3=)1?#%T;1`##@8$6`nh#X6j)-Q+ck%2^_%Q*xxY9Td(JC%lj0P~?EZnca9wbWi+vGtpjcVm17(R| z<9xP57+BOnTz~jRyd>K^0XP9F=_57BM7RS z)Y^Sc8+aYAo=PSDZEERPQH=P%&;SuA@X=uWnBr!LzcWtTW+5Oim)hZZrfa=2-ZJvp z$nim+$gfARv^(o%OKs5GkIbu==pe=pX;X;K5lSa?#)mCRZtccp!PxD{)`)of!SH-? zzv0F1%XIUuL31{w$&3F25wq#ZfSJaCX;M2MiRxN?pa8D<# zOLLbQvty}ug5)$B-Rjf());Qe)sn%uwK5;?j*dwEd1CLk<=WGG`Z`nf|5o2R*?|CV zhQXrW=ytVls9sI<`Em;5g2GbM#Tpg0`S~#GRiG8biLaNWB+|=UG-QEjQWhxXcIK4a zIOJL*6J#6sBYsCJn-m2K1kerV6VV#9LX-KvouLdL;LTeJX(H3fjrDlG>B*RiScgz@pV|P&y$PF z{2g&Rh~}tj$il+;Wp!nDO26_fm(V#li#XVW>k^#da72e7U@hmG_17yUZ?pU7Jp);( z_wL8x&Q*>m4Ge|vdpJ)G$c3{0{qE`c2ay+`Q2vSHiN80X&)cr9puzU$4kxoSHFYx0 zc=7#s-Gcn*aoy9u9RB}>Cn$nHV8sO(>`(q^u8s?2-+%HDJ$_pr8!1h@Qd+5BrLVj% z2ZfUaLy^o^mn3Q%S2@yZy%ed~{_ih3_>td#(t9BUZnIq$XUxQTOZ+mQBI6|Pb|?Di zH&`NI?t@>3TwH~yHxQ_yrvuOxi*!?N2);MEa)%XbU0=-1ZU>i=9zY%%Bw@%4^pPQ4 z1u&3vz@;?=79(MdL-t5pFDTqdp*jAw>T%6rP*>6M=gz4H@mYlwEcKdF7TX8^?Js#` z2QXYj+wJaOr`+6Gh)Te-KI=C0-NE0D2E~OQVD}e>bV>C_DX*!Xb*iE4y~AAc&Q{tR2akC-Z;n)RpYuAue#aS~_r^8~ZUQA|9HI^bY7A@elGKQQ9-{R4PCJGMXX=?Zi06BF17T)a*|Ju65rk=mK8DcQ-WhsM1SWX=4;9H zp?U)vKPSnS3>cP;I0wI#CFVUvyUSmQ-$G<;nn8AHc$(Il{c?+VI=mq`aOUXL9cot7 z3JSnp7xmd06!6=Q{I33NaoIt5>|E^N_`lWehxiNyHcs9se$xai5Yskl#rAHBS`xJQ zP2d@Ol%dv?yqbF3`icL$OudJvw|xc#ochCi!?o`vDP^KS_jBLoOoNxGwAq%cZPp|K zq1)6p_YeJzN3Lw_YaW3wpNw$Wead|jEb)+CrLw|$r zbD%)AxUPTiGK;-jWY~f*W>k*t7H!*tian6xu^3xd;PzRBc?9c6i}S8mTU@KN1ZztF zUIOLx=10$9D6`5ZcX=fkV7{|*@Xug^+zV2xxrKdJ4`b2vWlbP3LEFQIt8`oX{N#zW zM0jfYvNp7+wc&=l>el(;yl%`(!Ah_!eYQ0ZNB?%! zh4g55kw4*InF+w6&IhEUqI71#wg9SfgWEMlR{Y{sV`+k5y8v?4 z7}W^LD}-9Cjq2zP$K|)egU(>7CwFI*AL9Oh56334PeyXL6Xp2uD}Bk>keLzjyPh|v z;o2Xc)tmUCPbhqpebRl^Rp$K$WP7?2NidQKBHReLp-*DEd_b<5uYFfI{<%?H%KtuC z=I)t7N*hXVu4-SbB(}sSDS{FT+2HYgrcXiN%{LH0yG^jYE-@25D5bX$k^@RBp^|#k zjNHT?e7Rz_L^YbGo;(kwEs*L0tJP&sPfacLNL2S&{`+1NvO&=0VR$w3(Z?&a^^qp8 zF)r^1wS$sCccS}~k;_2cb zf}h#6t>b&;b^YTXs4YgSnryM9oiGaUDNev{9gSY^J{r^K%)!*b!i%L+t|6_$;D&S|M-Om9Wn8hPKg+{CRvmWMEh zz8!&-fXsI!bTJVMD0_38*xVgErdU-jfEK9J0EGrQjO{nOFL3btW6#%z@z;K%S2L9^ zeZt4MK1L3;k?Z`zddA?A*`Q-*R^%5ljr)Q@$$d9PBu|1MfK zUUPi|&j(XfYUkvCBXJ;b_ZB3lX4tBo*NxZCgD0S4i34p8NfRYsw+}NFW)OhCd5=?1 z^L6)Owd9iuR#GiUSw*Vy#}EIf1rRdO(|Y>A)RL>1hIeD=&!RQMh^Ny_M~WLSQ$6UL zz5lJe)w`K|csO7m3gkt3XR|az%~;?!xy+6E`hiFc&yWkA(hGzgOzM|Oq}Qk*LWn;x0VEkpB`v(|^FF@B9as-eRgzf!C9F|7 z%!Hef2VTZwNp9#un+IB7?AK$D9{vB=hDpM;!PuU|*Kbw?$(_pk$-kdE_kD zormBV^L!fT=VTdbI?o-cmolq7HTIx9WKiF0WSaJ$@WajMactiC8)r|7pSNr=ZTl-P zgBU5m%rQDSLBkakQ@Sa)y|M<=-kDX9ni>*{~OhI>6(>(-h{W<~GwX z8fFng_<+;={N4d%N2e}+oPl^j4{QDaUtK0>N2TsQ1c^PHiJ{csnWo`glN4#)uNNWt z&(nB?^S?-N!@&SGiJ)L1Q^E%(fij0w2_9xudUDkQv{O#ew3ROnu>J3ozbz6WzPPnx z>)&;)!D&qkc!|%mI#yOmLjk#rb^vDW9v8mUl#0^4RwK1~AiyopNB90`aDq=0l0bpk zk^Jt&Mv~wC5lFv$1^vANq2>RYz-If2fwp(<%B+^^w|au*PNc^WCsWjIZjc3{v1zFP zdfQ2e%2{N9_7{)ff=y`2V9ZldU~l~5(pCIk7TA&Nf3;$L6ru(S2fH^~Aoqh}=;VM; zYJjUCM)`*R4q)d~pBD;Hi9TWtFP zVqc@Oa!e|Y6P{eSx|1961}*dz<3D{_eDEAu^@rm8eOsLSe|%G?c0obt+O|nmqPD;t z<#%@$IZ#5ZX%JvTE}kp#`}lJ}4HQtieI}+WeLb<0`?+?1jnnT*qX+!O@jl5XnG!jd za&h~gn23!|kb=u>i@`nmv_i41sgVZQO{Y@9$jd3b(X)A$sT!~6SEdX*BmR4dTL3sb z2)f{e4xcj&Ut3xk3o2sju4&L-uxFwg_iblN5?-Us{W$j7D4`nLmw(=8EH+N^e|Cjs zMtrWlzFU|7lDVxDjVTDN7t2H>5?g%j!z$mLZ}{H(G*$5R->EbgsY{*|Oo+e|Ho*gx zNER`FyFcw;UUp1(vSLA)u&SH^lkVeZw9 z*+-jwjiD9|fz}b)>sykK@Zirbg}Kx^1H%>Y;6FRAxod7*1`Q8mVla&2 zO~)(-g_*kekrO4%zwwX2rajEY#uT!0wFs`EFoNp`x!?`-4}XBl`!I&3z|- zA9^+ZXiBhaYKR>}38onIhZ}6F?@tOjukFiihvI<~(G}vmt)%hp6A-7RP|c+4i?n9tab5$A$q$yHu&L`)j<+tF`;gwMmd)n~Ljag2E0g#(0JRGtFQ#+QxP>l4li zFzO@joFhnIESr=Vn#-ODUdSgRYOR$xUh-yhVZw#?VI=f41nL(2J7eJu&66oDJH!q( zp0PM+l(H=fNxd2`vODuv*8ff6tCk0MPM9{E$Q@6BlmXri^aBqW7GVqfcH{?Yf&@3= z3oKc97os8AaLE9)^6KbNv+&JVtH*msEqPD!21Rv(DW1@YAs89Y>!rxQ>|>&jC{g^A zQYhES=dMW+UMa}|^I$~6;a0C}`}da6_gi39 zSl_1W_Vp#C6Gw|l#@=?%TE^Z?T>oYs-F5HxQx#c{!i*Y3n}c$a&HDGjG2zbp9~F%c%8{qy5Pu0vF&eU*x9C(m55O-5c|CVy=_`5(+$1h z;d@Md_bAKD9c0=zqKw-L%Ws-q{W}@7QwIhy6RyIk;1((rutV=?_pFacfpU%R!3a%5U~L#Mpmn8v1~2$=#W{omrKirKGNbURQnw$QlTsI?zjGpKcg3ofII;R3}q&JC8!dQ(ozZ(<-_Ld|a;QoXhJL+{dkOWPJ$c&xH5 zGMzX4Cym1K7Lqh5hlz3+Ek(V0w?MAq=`3{VC8Tp(|D(mzIdXJJOJR6fCXhgwEcvT2 z>LDTP3CJ7kM0%y^9X%vb_t#{dn#^w#{gtfd%m=q6gT#O;TOh=xwxQp&N`B-$!SsI7 zl1csSXK4e@uR(VPe$$w|n)B)yD*4V@ENyyb#T9Zcnrcx=czYef)0YdiriH~L;f}u5 z$%-ZKPoJu5ekgU(?PeXP{#yO=iqamOSxV-XFZZm*x#MYo12F0vS_7Z7;Jxy!dVq+Uue?gGes5cv;)jt7p)~LQOE$LB^?~ z&nw>`-BSO7lj|8PX;F}M5doR!h|GKCGkjs{F= zH8{U6nk)B+=nKcI66|M59xZ8uJ<81v2)A56F%SWQihnqhEP!6e5PA zaZ(5JoFp$OG+*GX6|n7*YX zC3`z_R^?Sx-_bcd=AdDJ{Y1HkTJ_){pq`ku5g!E27$JC}R1}}{W9b6Pfwg{&z9+j? zV-aK+yJ8En$ryrY$dryFF6q4*eP@6RiqkikQ97zSdNq0ZE@qeT5v#J%yuhc-);l}> zXVGbf>8~xtkBcrT`^~g2_re2ug_XX?<`g9NC!R{`RJxaQvB-6LZxQ?(=^tkV z1|oQ!tv>b&D}O#uK)jvpnBkMA$LAs7X`3KSVFCig;x#)Ul)l(Mro}i{VVl5p{YwRH z?9GY{qTw2cx?H7D&1k%hFH%X|EBwhHrkOB_G`CiVroa55Q9+c{8aZa!S#ta@c%({K zWi1Hu!FZ)0cYK#_KlWvq6m%*w}$HixqAIo^C;dsY7)G`f1)fEEa?AMeOD zb^>HTxbr{B3^)X;7(?(9f2moGwK}6klaGxDJcvyWl5N)Ddqne@aLvGbK`S}r+HgF( zZ))U4`%E-2Zr~RR)xKJeop?O%z`_w42-5`|OtB@6S#}{*C6jul-4+ZHw?70tus!=H zV};`{nv{Eh7e-7nA)vBR;LaV5zraTM^~6ks&JVTRRS}Gx94x7|7j1isMn4A)s+e(f znRN^;Ptg2lAP3Lq-hx?IT<-ARU7=TJa(~isKam~e?)^Ik?rNA`O4Lyh{Tu^UE^~>W zWaWH+a#A!XtY@d&;PlJ44>un%g zB->0=OloO7Dl}xt?H(%jI`!px`W|2r&UUvhnQ|iwR06s$n7seNA(-0uuV0PeDZW_J z94Q2+DqhVnh_Ozh-E*r?T7jV@k((6U{P*=eY|i6JdW)q{3FBS8kh(1G*0->xh1HSq6I1N z)n>1umV`w*RR~9rME>4PISM4`*11DnwJPBtpEp`}O`*V%fZeSD1c&}R39K3LfBBHd zY*3^~=czgeIR!*ngfhei*B%R%X^?*Tr90{!S?jOjQJ|6x z*1rW-^>T2e`C~Upa9cQvaLdYje@XC6*0qNfb_kI$V|v>M*xCGlKm8B2U-vAQ2GqiE z4LBH?=fgj3YdfK?V3WaD0xrItWx4>}w+Dri8C1{a&9SfL6) z!y@})N1CQcCU&la3K(b6y@J@ZxqI?JjL0VXM`$;P+;xzfQ82tn>%PC2$;R~BLiOLe zIxy)&SR=*4|NGc;O;U2{bgZd(?6L;_2Z^Ub!u9c~>~8p;@w*pjbAHI!+1a>{yW@~n zgk^nZ9npit?tkr7poY#%_g6nPlguY43`;`pNbzJum{u-+HTaXTFzxW>sAK6FCnd@x z?%9S?KQsYH83?NI4L~4n5egdw(ktl*a_tbIf#4Gd_w%v$+|L&8cw^qFKDZd&6hcVG z(P<>181GFxQWqqJUj!0vKxYTw`<+EIP?e{8QQ ztYJymch%5XTQa04<%n(k7H@U6Y*JsECRw;lnxOIa`u;R9E3pSp^*TN_H2Wsd@9WAc zV9FIj${?^)#Ri`?nT7r~5ZN*Gc91moDCo?_ttWj{NslV_HA~;`MWhq6mxLR*} zVtM3~X6hHN`${}NtO`GeO{Z+n(q+oJrryGQ6Z$FRXQI}IFq8u8Ck4MO740*;c=wu27L-Em#y{9W!p`ZP{> z{|Xg`6OGmS8x2V0^znn*d!>5rt-u>{nOhGUEXJ?10jBV|=Jhy+4T=pLBCH|ZM5)Mt z?alk=H0t98bpDuK2>*~v4@fBb+txzxUKhDLziPo$Y+-mYJ2c=M^U=ok+EVHrZnzf4E1Z^NH=JyMD^yY z$zg?qdB0rBSw7h}eH)9pi>ogOLl7Ml9Y*| zky2fciU|Rd<-2JALYd!_OnusZ8mKA?{+%|EN6_u#S-$X>v*NqwnbX+rr*nbb{_=Y& z@tKblR>NNCs<8Xlwf`0=s+{{^!oo|;1c!AT>gZVL%UTnnodPLc368L|)kOY4Flou5 z3JQWb!Ljvx<__kohRvdg04f!oqUzfI&`^5`rfJ5A5Y!RrO2Ffo`1)~|lxOJc;;B*9PFo#u$?5Mq$xz)O7RS3DFFDTaieUOuVui>d!(PKC zG8hxgIsob0LTdl3Sj3c;ZDq;xO*S;JdaQ<7G#9X56OUeF#^l|}h_jas^*ZC>G;sB@ z3*^bWDK^geXKzEE_{siqmH-e=`Fs@|2b>bzH7eEav`cy1o`<#lL~LQY$f~vHn*5e! z{PQe@^*f5m%UL+_ks=nlGSS@z!HwfY!uwa{&{}2b^Y);&t)Wx zK>^CWd)p}tiiD#(SK=8$X{fJU61MVpYMpR#WABwYI-ZIQJAGhGwzYYDI|PL;2|U@N zPYNVed7&;DtJ3t`rjsZ1QN;WJdi(U6x1ZVoTJ{Jg0HknP&-mTB(JxfD+40RG?OC;# z*tBLd<4X5EPTh`)cn^dyaHIDw=m+q?7nczTVA(PoYAvGEtvbp>4iss)t@yPk?vK*~ zA?=Wt?Q@(uGIq7TV&>^{2NWHl@zw}USXo~pOm?1}BT#$QZ_w*wmmHGYJ%8a7cj(M> z$#123fHfs5vqM`cb_cZ>X?$nV>WYNZdQ^;j;LA_x+_OcV*OEO^Lkv(dDoE0nbP!Mt zd3$9ljb~-xv#p-un^M}$w+(s=58An}(0^K#6?|60Iw_FxFb7j(EEpbamol5er^7$;IbOD*#L7Et$j@{Se0M6 zFOxgm(ca+stHeR$7m<=u@!VLkXE}8)3-iAzESflmloviVC#dmG-I;(^Ou7&P6*fhJ za0qBx8U{%Bu~EA1vM7tIS#nr-o9E|U>(ND49RhML`31hM!R2 zpRKRFPpxE}e+L9ZOsehH z0DGf~?4ia5T2(n^ngVUJ``={YK=Xt`!%Z2YHuB5?0F>pCcP5k&C+g_R8`xLB@JkgY z_{vWkhSF@qJ9rk!71XyFUoIR=!n|WgFPOoqzoTCv zEd0~c4qF^KN?-$riC~;l)`~J1tgW-@Wuc@1XRD6<#bQ841fcSpE4px zlzzULX=_jYVRZ7a83qqi_`F*YCterTW1F;G_q(ZScyxK>NT=bLu9A?+mJUTx^}r_I zl+ElCs*U>Y{gN=&As5uyNztW)hj|n^z>si}&7V(koDtH3yPMz5Oy`u$bFAq+BXtXjY51FlIT%j>Am8 z)n^JZ0SjhUKVQ0aSf&za<7Ty@3o__j#{(8mJpDqG0Q+N~Upv(=fuDYT!qEy3=j~)g zv-fJe1+iIFL>}LUuljnmb^S^^8(0kRbh=*>jz}2-tfP5vb9&Esm*X7)1@DXks4VUR zF8*G1b9u@fnsG)&VV}>tI4t_1*MLM=5FW3V0NNCZx1BFU8f>#m+M+b58v22f36*Hq*q zR*rWj%CHAb^K+i;ot?k=H_c7z+Rce~gmXf?49)&dtfH}0*_dBH2HDLZUk`_Ib8C=l zV8Wat`vxYyX)^RD`pqk9HC>j<+*8b!av`KKGbgbe>1>?fVhBMcU>>DkOu+lp0Ch~5 zV#lF*z=(BX`g@D#31JFVNBjrhR{jX1pT>rjq5hU-zi*@Hj2KKrb5{!)Hxd_GHY_aCUh)v&yEj!Fn0)lGV_ zTD{!=PEeF8CvIAS@AToU?OpX#NAq)I;iGfUOPbVx*q?jU&>o=dBrSjvH-*-W>X#%aiRo1od=~fd&lo;AI0VWvLERyZu z*iwFTw!^hVfOpqlKY$j+bV_1Jc@Rl$M%EWZ~Xd{SxRXA zd?ii_&B?DwY^l06@ubl4wQxfgPK^4dc+rli@?i!fF$z|;y9JAHjWJ7WENxO@g)LQ{xYgAh6Q4c<#8B`aC*RzXeVRsi-whd zrz{~ia_jkqxV!S{bXCkf=Yw={Y9Q|v32qV?WLIhNvx*x5{mD0yJdpI~TkNT@&kK6n z7peJS-fQs;5*vTo2Kje=Nb}IbK6nX>=K3znu?|^XG>dZjdOt&7U!*0D{$jR2T+^ivuhd zCaT_BM{ZOdO%_v6-4kHzRSq-}x;wy~JCA}~Ig7|g7VRJru9nF*2|Jz~rI4KtOo2u_SXptd8UeAIo<{2z>n?%^`smKzy|&1hyIqpcOBdF+ z&+8c^wu>WE&*1&XW-pb&4eUL~<<}qBT-+Q#Ir;t-|8um>YoRD+W|Jp}&;OpCN~7|iwa_!~mk&J;l)oLTiAxW@!% zEEAkNpS$LXOIN8}=R)#b2Ja2qr95)6r|@TEF{FME<4%o;kt}=XQ4TqTv7Z%&JWiaG z>kvfTC?fXY4YJ|wgDnGprZsBHL9?w^<%ncV!*>d2FgE9{nwv>YQk11K3r=>ON#!7ND?jU~DVl|HpikAd_m@Rz1546)mu6^(SIl_|s^ne;wWrqsNFY=>juK**29VQ{K#S`Sc~( zIBks_-h4oAUb^9tYd&{UK3W8;6FXiUi4i-7Jbw6C?%lro&+*am+sEFy%@_45p)GMV zYYOeTrOfq89;wl*t|U&CRH8+TCQqI`k;ji8%Y~!oWZlG-vS-CkdHg8yG$wAMcv7%N zQOQy`yTnTzPwwBjFNZfCknIaL$((p^`^?2MjC(GN>XG@r5~G19zS{nh2AW4f1DmE_vODR`~|03=;$$HNX^&l zN#VLBR9PO}d!SY?Ve-V1rBF7Bn=qcfw|lqm$-2oaW&Pwo)jE1hvX^{Ws$=I!mM-}} z%8cDZMep~%%ns6K7|O1t+hor7Q`J?^St_^G ze!C&I`1tyFj~+gf6~h+z&+oe>q zvXVSwN_CB{p1CX|-WcdTU*F*U2Onl2HgNZpPC4Fj3QeVH^l>YR6F-jJy?IB5y*5BD zoxJeW07jFnMRQ2aH|wjyQ>kuUy(RO1o+0~I?N(*-lw>cFOX_{lR1!j&DR*z(ku?)m z%7&?{8KXiMQF19}SmYwi{+M=zCC_!Ci<#3MKS0-w}z3SGHdEh*i+oW7DB z3%AJZ@1{Kedaz{{u3a3yY(0q?JEkhx(QSuh);E*o`h{z@zRpAlOxyK+Rgz~&spq9n zziP}fSu=k5bIMu^?obi<{*--x?uM0~fOOs?Tkpr0P8L5Pfs|-eMy{Q^Dkt|G*LU*( z@s206Q;t*U8gJB>;tfivVcRrgtt{+6JJff`;IVk)GE%W)HHj4`mOOm$P&Un6Crf{y zXPZCG0JO&IbtQkbB6<(>nb*Nz|8wjgavwoyNXb{Vkh&$hyBzpiM!xa8x+5>5#7!7i zYQ57$0?X#s$G?5;mR$P#qMSW&O8(w^LeAiyH=y8j&s-8MI?}TiVWbe(q_N#Qyr z)RowPLdxO+b3*6?bI(kv2T8-;EhKN{f@-B7L8&KwGDZ%q-)D;smG4kh%C@Z}(W3`l zZ^vTn7=5OMx#*dZg=zX(8x2}-UcMo}w)xJh85mn0+UQBb3oP+>B^tdVRl3*GIEadz zJYx!p2Lr*i7?oZwUvOh$@G7sWt`M|l={_XB^s)fTwG=}X#M#DPQpK*^6414 zb>(K5r=|Z)Uz@%?tA@X{>?3!s-!^+2KKr}W+0#lhyc5w|j%-7kc45C+n#FQg+)`+7RCB9zsVSvemDlIkHg6M( z6w|_gCag%o8bze`JB@U!=Rz$Vyg;)LW+h|Zm!#qUT1wK?0eXu22>eF9HAv1NNDWVt zWk?|{zwV&HmuX*&H}t!R&g(0GPm5avMX~(V3TqHJsrN|iPK>) zJy>NG7$ia*iXv`uxg!i7N51*HTswQk?MIJfg?=8~^h)cBQUQS;!AbP3#(gl%&_mIG zr3FZYiXxS1$D%D7c+iKteeJefJaJBLUB0RQZ&>;2q-g`RaFexY&Y<)hEXAtP$Y@Mh zVdx$b1SEv}(?&Ao%c5K79PBPT7HxJQNXavxvSLumO0U+?XQv@LviYFq?W;q{1Iy-B zU>ReOkZv1yG-j-_6wZc>$QAix=pxy>a#tAdDg1knWQBfSPl_YWQ?+|-6ewb7G&~%Z z)`-MGR857>Su!xx!>PoKsLvieEvNRMlsi!Jo0qQ36>MuykiAHrDU}qeRZOj`3kxqT~yZA}sOOsFA2tO`lr6K@8@YX-KPGk7w9*#oP5QwrM=h1rOlv~Q zwylgEw;~E)x1r>y6~JVLSE(mv9uEnk;+dP_sRu9H$;3UFEX=!7l@^z13QCg6tsPf&TYS|H7CWx|Ic~YE9`lDsp$V=vcAP>p;$>jPT<+<8CpEY;Ebx0X zL#Y`_AKJKIU5q$LyCsGNNrFEKlP1&*L|ovP3Cxd|C=N>2@icWrfBD+^t8xmt(Vg(W z&0v~i#f>HP|I_w_|y}EK$d(?vKm0fl( z+b)ZLn=7}0x$%ZLPN8GRi!DX!m6Y`8R@pXxGeGnEZCwX$e?;!Z1c-?E3;J%<=tod~ z29T^cn#Er*+mf$JA*uCN11#js2!eEfui`&Qyu`cis zsmm#!jgw=5C2*HaADBs-Ax`c(u9ZVih~pGG?1%JBWN!DO^2IT-=Z9e>xUuWLyiq$~ zy7Hw0StUn_mnCh^^ulaUEEp!P#Z6?E&K*1@_kk17UOX2JNh~?K{V*WGQ&9RgU;GMd zeSAov?Rv%j^LT0nxZ6>gSzt<=BfVrQkVVpFPcQM4CeXl8dwZbd{MhM+YzFqmZgHGK zd&8pGy@>wvg}nkqE9NPmPbzh-q1Kr~*JQ=WC9)bIFT2HY3T^iT+HpSLaCYo?QqP=& zu}W=#&R#|a%Y}1i7t+P!=WV~BU!iSZQvV4KjYyPJXrjF`060#- z_*o?MubRNr8do?Qn!kXfHX>08`){7@D$O3@u%{0vBDw{_eJ)=d7f%SW=R%3Xt z-xL8DQeLEh%@ZFKVYgMl$Kv8Nq0t!k{xH11^Ge*)?DMv0<4UFtw;QLg2^Uixr4*Wr zO}-8$@u|>vL2GN4$T1$yL2!CT^cE6V&Gve3gZFMd?S=Ein>BpSDD(-zsuut)F%1!S zLQ)>2e>tNP^~^>!k^CpbT36{-Qw9PN4KN|cfYh4`NGhSe zp^0Ci4QhcuwP$dl3>?NFyKtT2QlWiSZRDWAV6WlW_lE+B_Xt^zvf4_=j-`qJ1E&1s zMIp$E-S~v3uAlO!HZQw)1)sg78wwqTwmAcf?mxtHE5PXoExv9KcP?{qLozcNgF64L z7iHUABlKpz49jTdb*Z6w=t*!r*f_wFIR9?( zZ}V{c^HUQ13LRDS%kj_g{0EREby8uzpIzc4wGYWW8Ye*<i6M7i8 zVUEqRAq)OtUwY=*BSoS8+r8tp&oMWc4kKHbVg=?{D*U%SO=aa=?&TsrhCDDO$gz zmR-%_L(E?LMg1MEbIrFJDqS)I!-*e_j1b0PSfOoMR14r0BiAR#TI=t-2(9U}^k2l) z5+6VoALcbl*y!$@kJcD9=i;nW5=IORib~=EZ^7L1Kt}?A+en`~Ban`%r35k^x3AsA ztet6cZ0n&2&*@!-j_`IY9)QK*aGxwivP*81Nl9y~Oi53LicLzYGY9|1xQn&AKt@^O zy9|0=(&fycb5=N8}d2t_T04za$>b&_v7 z11YEoSb91ug@-*t&`69kN3$IPZ}Clxnh2{ag89!LBi_*krVBImv$4uu?HBu3fTKX_ z{imsp0-=zUhM11+U^O)&QCgw70L*%@{+|Pkp!qOd$ThIjwK%l63>wM$f8p3U4J7$b z;-5+BOD6xyAn{I>C5`@se>e7np&p^JGV;Qr`Z2j)$qn||mdZX$;i&1^-%pc0a2Jh8 zlvn6y7lC|hML`6~E{M1opAeeRE#U+vVh5r}k0G(+$5zYi5{`6Rj&aOp2_22Rlo#rl z5OEc89xcD_2t$%dm*K?Dqab`7YnZ(>VuiLbTCnj{=HEFMFn;0$2=H>~=m}Py$Q=Ky z(uQ_&Zhd>u*R}}rK{&O^F9ue?!GRV5QVN47mXdDQMV!0D&7 z)|`FFA(4-ebG`zi6LybV$zj`lpI$wmhf}R~KZ~OjI;`SN7Boo~Z&(T}I+tbjxIY}h zyKMK1@So3~6>yY7hxSNpeB~g=EQPac9&!SzMRv3L+q0CudKO=(0UKXi$160`X05;P ztTQ|~(RKWX!{x+|qsE`me`KVt073cWsblu&%poxO-wL0G!0OF9TcOdUN?rz5{V1nE zA;5Zmc=NWcE{N6Nv*aEdI9_%u+m80LdUIa>J$Ki7A2d;3Xy~&B)nxAXWV5lO6*^_+ zRML6i>zZWe7tZo0f!)D5V7o$Nn_e_W1DC_B#FsHO^~A1YvK64HbBE99&hjGWt+QiP zHQT6MMH0e4C;e}9n8P>icnywK=xE46mT6T%Ds`@|&&fi^kna6G!uoEB!zG(SP zN1Xsg>&!Xf6}k7|{X&++gcCT)2@oaPOaIHtxM^@DT#ELd@x?18m=J@E8F zr}TnCo5Kmy+Ucm{r$a1R)R>N8@J^QaNfO`%)`N?bUrX*G5r0fOvfbzcFeGFJB4G+g zG~dQJU=9kUn{w~=-5?N76lfwymNA7i`=Xts#Mo&*(DYpZ^sLH3ZlcZ(K%UkW}oj#8qY|X$qB@6-aK&{6pXr zmHy0uQ{YFw;j{jDg-+F^OpEezV)rripUhXpD z(yEVAd-;lRP)_a&`J_O#B3h`4fkB@v<+=uc8Xeb6r6;(ZT{48Mat%U-Q985$Qn5>Q zDc!uhlEW+?wkS*pCnO zHIuV}lSzG1IH!+I2Z@p=Rq7_DnXL9{qe)=yodjjkbuIGUm;oK*&80Y7}k80)77u_Mt%EUhywb@Z6g6{1S(#6U755N1iiQ;{l5TI6oi=sfaZ zfgU`0>R8j?-nDeQEFrT!Gbj$0CZO-iRVKGKx9<64N2qriA2ZlFUYQo!wR4?4bQ_Oo>KSn-ikes_aUfQt=-TvopN-)BQTRvj3tR7?k)KOC}DH49fZ8&kN#F{-R z-mtVJN&$|Fr!SttB)eyp4PGEu&RmX!y9@i0F=EG%PQUh)G}+VXd!+w=`oQ0^b@m21 zv}u2E`jS>TYtbB<)naAw@~MmZJp0%D<#sS^k3!>1WWZyj=ga7dckJIoGUm;!543df zd|5YXmC>I@>ElS9RjyqX?ZAl#FuZGvX2;E<0dwT|_9Jd9L8uF6!%ju8ewW{0*Zcfy z#ZEO)bgdqMY>^gt^Q?8+Aw+5jHea;GIFZ!Z(!w$$qeUV{6RAsE6q+2rIm-khIz5Iz zN3f;bwyad0f9omffm5bJFZmq-1)^lX1g!vl?#zXv!Oa90l>>Hq5U%Bdz*$mIMf( zN4-5r&L0jr&nCRQ**|}0i$d22fgVY#xvg+e;?6}|@kQ>CYv|Qu4%y6?)&Uke(^Ct* z0xkzzpZxOg6(EZzrwrspuO!_PT5DG0)=XHA!SKs%-7(FqeOk=CX$x8j>cp7$hU#Q1 z3U`^pbxNpWCkRNSo%d&u$w~zf(?i_OV@=Cer$|X z;{+<+{-4;8T#q>Gt0xq<3Qx<1A%>^ zE5dOi)RR$f50;zIB{O2HLNCVTqRXc*BHdP8{UvsQFgbqb`YnxexybbKj3{=ACa*}f z*Xrmq9YBR}>ZfCati6Pvt2MI-zWy6pW^$3#d%r1y$Dn|b@yz-utB@U9jC{5G1qwUX zBp!-loSi~9BQ6k#%vcFJRyroOQU*N3 zdkzSf)69++;M3>8cg{>f_1EeuNQ&=(C_k2)$!TF{!0ygE(ce!G*CT)RB5LHwB1#K= zWb;9-d@?AcqH`QF8##_)I1>?hJjRHG^sH@F=!5I_$?EaTQQ12gT*YF9n-tZt>?LwZ z?sEB{=vZw0pH_~htPb;@NzT{&%XV5__lBNYY^UVxPuf#6*yPvKuLn8WZvUl zSY+aMwk_Bs^ZU*)OwktdI<_kGseLCTVL)Q_;m3GnHvs0ZLsn zLWO2oxMHX3dOYREr5o~lr@oOI$15n1%>980`` z|59kPTcs;)M}^+8C}^&BT<}`F1PCZ2+{}ftX-bJNj6NOzE+UtM=O6^L!!O+>ZI1MM z&Xd5@GGI_FfI$h}8#V8v8oX?nhvh+fv*AZA{zYa(D^18G-L#`y4%xN~ah=%$#b8l` zM6CU^FDJ-BcREJAl;+f4Owg`9i%Bt~kCrWTx?Jg{$^W&1FBG8k0<&O6Z=qu45^f`` z{$wwnONzmFW?i3RM4@>c=?hx*>7<|>x-nBR8h1Z-0bi9w2!@Dnpzrvsa0g;Kli25l z{by@h%MM{I((%_Gk}O>c-Q_sqd1CKThO-EE+{5$d@LW7&NulFmoNdPeuK@*?LM;F- z%^yP-hKajiWvfc}Iv`9eVOXJwI&1k=M|D4Vjlc1t$6?|qX>EN~0@4RyBye^u?UNZb z1)_hZw_L8(U0@qlAZXr+-Oi5ZCmD13II3m}I{e6$i^;~E4ivHRO@pVQWtYhbIU0y96K3GhnpUYvdH z5~eB-ZrCSFP^{wg0V@&*1+GS*sLg>cYzhlqtj3)`5)v(eQ_ufw0^FE+PvmEUd(YVw{!e3@<7l|Xq%@l_#Z)J-@yA~w8 zWEe@Tn}69}(t(57CHsKDqU&JP7iKpD@g2;V(Bg2M#G+qkqpZ8ZbRyo7FB3QAVqq%Q zpp^CH1xSu5$~F8esFM zOAaB;M>bJ+xXuVOBzdE2Rtf_q_MWjY;X7h1ZN+LLGZfW()eUZ#y@`tHtUi+!++#*a z+{4^7bNn82q+P5gC~OGUx2E?m2WRP9kqN#7~#xtopxiJ3r=irfqs z<*dLe(@K*fVhW(S*Uov)m$p$~Bm7>xrqH|-BJZ+bu1Qi@Ych!vEMo?%%k&-b(v`sD z;2Ixs9F?|teWyE``!2$_5yk;@6-t4ug(K={wP{(8ZAVu$KH@uWR83u{XL_&&pqk7c zk$Q|{({`Z^Dk_Lq723`PV~|;`XI-6Yhx>0eB+^Yuj9xqMckxngu6pG| zR1+3q*u|3%{hL8~|A(^R7YwBYO_*lhVLV!v*of+mu+gFo=9ec< znN&Z9KM)+Q8Nb4vQ#kTipI2zEDygKJf8JKogG!5VRaPvuO9MI9N3dd1O^9A3bryqS z2En@kKb?bVtL|W{S()T?gd8OUb$M858TQ(MNCquge^VY;X!^P?l0xQIA3k`Xz;-r4 zl1GAV_jU+Td<5VS>_g$8OtM{Wn7Z1Q!izT}^@!GkL2)IFYcAESoF4iBA27?~hc~)6 ze>``^{L=X2R!`{k%iy^4tXX%=&hU@(o8dA(Wt6&pOER#E={4-5meHa9u-pgqGgb=Ekm~(O@ky zLNnpcHN1JOEecI*QS;3P3QwdZrcjyKA;GR*f@?0GxTw^qaX^ehECK!AbZ`$ij+nVs zmplf>;QY~ZGWW-6rV0oAfXFRf=bc8u=VT#*70F8{E_icoJ$@~c%)C124aK9g`C)#) znX+x(CbzEp5`yWbpuA-Ug^<<3ul7^27he@?3JnXK9l6X(XnIYR84yzV99K?X(y}aR zQ&~76=R#P)3e#>0lO@zHmaI4rKZqoVx@p<3pT8=rz&N@Mqrlu179vVEEsLQ;74-uO zY7#xH9Mji9PZm{#-9U~0*Fpk84?)>DV=X%4XS?-y2|*Ll^t0AM#F$to#vJ7G@Kv#- z&>7&fGG&$!7MF5#>tT>^&( ?jqnA=+M%aY*rR|pW| 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 Eg2$+2=>Px# literal 0 HcmV?d00001 diff --git a/osse-web/src/assets/icons/favicon-16x16.png b/osse-web/src/assets/icons/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..c1a07973bd53e3a8888a014660b38cbb4a6f6d21 GIT binary patch literal 733 zcmV<30wVp1P)H)QfmZ2XC?sz^$@tCK=W&8H9AnI81{>G!ZyL_G3c#Mt5|<}54mR_aUua$ zADJd+U(L)%nIXA|`A+^MD z+U~+2WhP}XCS-~vD>b^Z1Y08S$}{60Vw3Oa71X0q@ps8A_Q)K}v8K?RpdZoN*(nR;H z3)QVMQulA*%kzy%>0d8I!Ti-Q>iM4+e=@NPk<22hpG$~luAp~3po=s@r*=rJ$|Uhz z$3NbZG}0tYj9%AKA!cvLi_`8Y#P?`0k_&d$?a^7q>O+K_uN5ZY%NE2<)U$CkYfX6* z{}-YKU&x1cqh%Q&J&?O#NmOhK#)I+scFP3XGqJ%JktTn7oi56sc4gHc#3~i*t`yI` P00000NkvXXu0mjf+3rZj literal 0 HcmV?d00001 diff --git a/osse-web/src/assets/icons/favicon-32x32.png b/osse-web/src/assets/icons/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..071b9da333000233f7b1b9e2bb4b153f55faaa7b GIT binary patch literal 1828 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⋙$BlJJ%f79>NUopTLa2O~U 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
  • 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 + + + + + Osse + + + + + + + + + + + + + diff --git a/osse-web/src/main.ts b/osse-web/src/main.ts new file mode 100644 index 0000000..f77061d --- /dev/null +++ b/osse-web/src/main.ts @@ -0,0 +1,8 @@ +import { provideZonelessChangeDetection } from "@angular/core"; +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +// standalone bootstrap +bootstrapApplication(AppComponent, {...appConfig, providers: [provideZonelessChangeDetection(), ...appConfig.providers]}) + .catch((err) => console.error(err)) diff --git a/osse-web/src/styles.css b/osse-web/src/styles.css new file mode 100644 index 0000000..1ae8963 --- /dev/null +++ b/osse-web/src/styles.css @@ -0,0 +1,70 @@ +/* You can add global styles to this file, and also import other style files */ +@import "tailwindcss"; + +@plugin "daisyui" { + themes: night --default; +} + +@layer utilities { + .drop-shadow-glow { + --tw-drop-shadow: drop-shadow(0 0px 10px rgba(255, 255, 255, 0.35)) drop-shadow(0 0px 35px rgba(255, 255, 255, 0.2)); + --tw-filter: var(--tw-drop-shadow); + filter: var(--tw-filter); + } +} + +@layer base { + ::placeholder { + @apply text-slate-400 opacity-70; + } +} + +html { + @apply bg-inherit; +} + +:root { + --tw-text-base-size: 1.05rem; + --color-primary: HSL(165, 50%, 55%); +} + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + vertical-align: text-bottom !important; +} + +.loading-matrix { + @apply animate-pulse pointer-events-none; +} + +dialog::backdrop { + background-color: black !important; + opacity: 0.7 !important; + padding: 0; + margin: 0; +} + +button { + cursor: pointer; +} + +input.input:focus { + outline: 1px solid oklch(90.5% 0.093 164.15) !important; + border-color: transparent; +} + +.toggle:checked+.label { + color: white; + transition: color 0.3s ease; +} + +.toggle:not(:checked)+.label { + color: color-mix(in oklab, currentColor 85%, transparent); + transition: color 0.3s ease; +} diff --git a/osse-web/tsconfig.app.json b/osse-web/tsconfig.app.json new file mode 100644 index 0000000..374cc9d --- /dev/null +++ b/osse-web/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/osse-web/tsconfig.json b/osse-web/tsconfig.json new file mode 100644 index 0000000..84fdc5b --- /dev/null +++ b/osse-web/tsconfig.json @@ -0,0 +1,28 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/osse-web/tsconfig.spec.json b/osse-web/tsconfig.spec.json new file mode 100644 index 0000000..be7e9da --- /dev/null +++ b/osse-web/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} From 3f897b87cb80b4031a521e8a37f8ade30f5fb952 Mon Sep 17 00:00:00 2001 From: aMytho <58316242+aMytho@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:42:12 -0600 Subject: [PATCH 3/8] Use caddy for all deployments, remove extra cors on broadcast, fix a ton of issues --- .env.example | 10 ++ .gitignore | 2 + deployment/Caddyfile.template | 52 +++++++++ osse-broadcast/.gitignore | 2 +- osse-broadcast/dev-run.sh | 10 -- osse-broadcast/go.mod | 7 +- osse-broadcast/go.sum | 4 + osse-broadcast/internal/config/config.go | 4 +- osse-broadcast/internal/server/middleware.go | 14 --- osse-broadcast/internal/server/server.go | 7 +- osse-broadcast/prod-run.sh | 12 -- osse-core/.env.example | 6 +- .../app/Http/Controllers/AuthController.php | 2 +- .../app/Http/Controllers/TrackController.php | 5 +- osse-core/config/app.php | 3 +- osse-core/config/broadcasting.php | 3 +- osse-core/config/cors.php | 10 +- osse-core/config/sanctum.php | 7 +- osse-core/config/session.php | 4 +- osse-core/routes/api.php | 8 +- osse-web/src/app/albums/albums.component.ts | 2 +- .../src/app/albums/view/view.component.ts | 2 +- osse-web/src/app/login/login.component.html | 33 +----- osse-web/src/app/login/login.component.ts | 28 +---- .../registration/registration.component.html | 32 +----- .../registration/registration.component.ts | 24 ---- .../src/app/shared/services/api.service.ts | 33 +----- .../shared/services/config/config.service.ts | 18 +-- .../src/app/shared/services/track/track.ts | 2 +- osse-web/src/app/shared/util/fetcher.ts | 2 +- osse-web/src/environments/environment.prod.ts | 1 - osse-web/src/environments/environment.ts | 1 - osse.sh | 103 ++++++++++++++++++ 33 files changed, 212 insertions(+), 241 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 deployment/Caddyfile.template delete mode 100755 osse-broadcast/dev-run.sh delete mode 100644 osse-broadcast/prod-run.sh create mode 100755 osse.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ec2e46 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +OSSE_DOMAIN=osse.localhost +OSSE_ENV=dev +# Internal ports (never exposed publicly, but they need to not be in use) +OSSE_API_PORT=8000 +OSSE_BROADCAST_PORT=8090 +# Redis instance (include the domain and the port). Valkey works fine too! +OSSE_REDIS_HOST="localhost:6379" + +# Storage +OSSE_DATA_DIR=/var/lib/osse diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acd0b35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +Caddyfile diff --git a/deployment/Caddyfile.template b/deployment/Caddyfile.template new file mode 100644 index 0000000..3aaf718 --- /dev/null +++ b/deployment/Caddyfile.template @@ -0,0 +1,52 @@ +{ + frankenphp + admin off + auto_https disable_redirects + storage file_system { + root $HOME/.local/share/caddy + } +} + +https://${OSSE_DOMAIN} { + # Laravel sanctum + handle_path /api/sanctum/csrf-cookie { + root * osse-core/public + php_server { + env REQUEST_URI sanctum/csrf-cookie + try_files {path} index.php + } + } + + # Laravel login + handle_path /api/login { + root * osse-core/public + php_server { + env REQUEST_URI login + try_files {path} index.php + } + } + + # Laravel API (all other backend routes) + handle_path /api/* { + root * osse-core/public + php_server { + try_files {path} index.php + } + } + + + # Broadcast server + handle_path /broadcast/* { + reverse_proxy 127.0.0.1:${OSSE_BROADCAST_PORT} + } + + # Web frontend + handle { + root * osse-web/dist/osse-web/browser/ + try_files {path} /index.html + file_server + } + + encode zstd br gzip +} + diff --git a/osse-broadcast/.gitignore b/osse-broadcast/.gitignore index fedaa2b..9f96217 100644 --- a/osse-broadcast/.gitignore +++ b/osse-broadcast/.gitignore @@ -1,2 +1,2 @@ -/target +bin/ .env diff --git a/osse-broadcast/dev-run.sh b/osse-broadcast/dev-run.sh deleted file mode 100755 index 471078c..0000000 --- a/osse-broadcast/dev-run.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -# Dev script to run osse-broadcast with the envs. Change these in development ONLY. -# If you are running this in production (as a user), read the instructions. You shouldn't be here :) - -export OSSE_BROADCAST_URL="localhost:9003" -export OSSE_REDIS_HOST="localhost:6379" -export OSSE_ALLOWED_ORIGIN="localhost:4200" - -go run . diff --git a/osse-broadcast/go.mod b/osse-broadcast/go.mod index 358df4a..bb398d5 100644 --- a/osse-broadcast/go.mod +++ b/osse-broadcast/go.mod @@ -2,9 +2,12 @@ module osse-broadcast go 1.24.1 +require ( + github.com/redis/go-redis/v9 v9.7.3 + github.com/tmaxmax/go-sse v0.10.0 +) + require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/redis/go-redis/v9 v9.7.3 // indirect - github.com/tmaxmax/go-sse v0.10.0 // indirect ) diff --git a/osse-broadcast/go.sum b/osse-broadcast/go.sum index 35a6576..2911edf 100644 --- a/osse-broadcast/go.sum +++ b/osse-broadcast/go.sum @@ -1,3 +1,7 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= diff --git a/osse-broadcast/internal/config/config.go b/osse-broadcast/internal/config/config.go index 284c0e7..7e5bc52 100644 --- a/osse-broadcast/internal/config/config.go +++ b/osse-broadcast/internal/config/config.go @@ -12,9 +12,9 @@ type OsseConfig struct { } func GetOsseConfig() OsseConfig { - httpHost := getEnvVar("OSSE_BROADCAST_URL") + httpHost := getEnvVar("OSSE_BROADCAST_PORT") redisHost := getEnvVar("OSSE_REDIS_HOST") - osseClientOrigin := getEnvVar("OSSE_ALLOWED_ORIGIN") + osseClientOrigin := getEnvVar("OSSE_DOMAIN") return OsseConfig{httpHost, redisHost, osseClientOrigin} } diff --git a/osse-broadcast/internal/server/middleware.go b/osse-broadcast/internal/server/middleware.go index 87844b2..1da0db1 100644 --- a/osse-broadcast/internal/server/middleware.go +++ b/osse-broadcast/internal/server/middleware.go @@ -1,7 +1,6 @@ package server import ( - "net/http" "osse-broadcast/internal/redis" ) @@ -15,16 +14,3 @@ func validateUserToken(userID string, token string) bool { return userToken == token } -func cors(h http.Handler, allowedOrigin string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // If the origin matches the OSSE_HOST env var, we can allow the request. - origin := r.Header.Get("origin") - if origin == "http://"+allowedOrigin || origin == "https://"+allowedOrigin { - w.Header().Set("Access-Control-Allow-Origin", origin) - h.ServeHTTP(w, r) - } else { - w.WriteHeader(http.StatusForbidden) - w.Write([]byte("CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.")) - } - }) -} diff --git a/osse-broadcast/internal/server/server.go b/osse-broadcast/internal/server/server.go index fd9a066..98bb963 100644 --- a/osse-broadcast/internal/server/server.go +++ b/osse-broadcast/internal/server/server.go @@ -3,7 +3,6 @@ package server import ( "context" "errors" - "fmt" "log" "net/http" "os" @@ -21,13 +20,12 @@ func Start(host string, allowOrigin string) { sseHandler := createSseSetup() mux := http.NewServeMux() - // /sse is the only cors route. mux.Handle("/sse", sseHandler) mux.HandleFunc("/stream", createFilestreamSetup) httpServer := &http.Server{ - Addr: host, - Handler: cors(mux, allowOrigin), + Addr: ":" + host, + Handler: mux, ReadHeaderTimeout: time.Second * 10, } @@ -116,7 +114,6 @@ func createFilestreamSetup(w http.ResponseWriter, r *http.Request) { } // Make sure the file path is absolute (don't serve relatie files, although that should be impossible with how we do this.) - fmt.Println(filePath) if filePath == "" { http.Error(w, "invalid file path", http.StatusBadRequest) return diff --git a/osse-broadcast/prod-run.sh b/osse-broadcast/prod-run.sh deleted file mode 100644 index 71b5199..0000000 --- a/osse-broadcast/prod-run.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -# Runs osse-broadcast in prod. Expects a valkey instance with a domain name of valkey. -# Running docker-compose in osse will do this. - -# Wait for redis (valkey) to go online -until nc -z valkey 6379; do - echo "Waiting for Valkey..." - sleep 1 -done - -exec osse-broadcast diff --git a/osse-core/.env.example b/osse-core/.env.example index 6c6e033..dded0fa 100644 --- a/osse-core/.env.example +++ b/osse-core/.env.example @@ -8,9 +8,7 @@ # OSSE_REDIS_PORT="9005" # OSSE_SERVER_URL="localhost:4200" # OSSE_DIRECTORIES="~/Music" -# OSSE_ALLOW_REGISTRATION=true - - +# APP_NAME=osse APP_ENV=local APP_KEY= @@ -77,3 +75,5 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +OSSE_ALLOW_REGISTRATION=true diff --git a/osse-core/app/Http/Controllers/AuthController.php b/osse-core/app/Http/Controllers/AuthController.php index 213a9c8..2b5868d 100644 --- a/osse-core/app/Http/Controllers/AuthController.php +++ b/osse-core/app/Http/Controllers/AuthController.php @@ -81,7 +81,7 @@ public function authorizeSSE() { $token = Str::random(25); $id = Auth::user()->id; - $url = config('broadcasting.osse-broadcast.url').'sse'; + $url = config('broadcasting.osse-broadcast.sse'); // Give broadcast permission rights for 60 seconds. They have to connect in that window. Redis::setex('sse_access:'.$id, 60, $token); diff --git a/osse-core/app/Http/Controllers/TrackController.php b/osse-core/app/Http/Controllers/TrackController.php index 4aca749..8d163fe 100644 --- a/osse-core/app/Http/Controllers/TrackController.php +++ b/osse-core/app/Http/Controllers/TrackController.php @@ -56,20 +56,19 @@ public function stream(Track $track) return response()->json([ 'token' => $token, - 'url' => config('broadcasting.osse-broadcast.url').'stream', + 'url' => config('broadcasting.osse-broadcast.stream'), ]); } // Generate a unique token for auth and allow track access. $token = Str::random(25); - $url = config('broadcasting.osse-broadcast.url').'stream?token='.$token.'&id='.$id; // osse_database_file_access:1:1:abc123 Redis::setex('file_access:'.$id.':'.$track->id.':'.$token, 86400, $track->location); // Return the user the token. They already know the track id. return response()->json([ 'token' => $token, - 'url' => config('broadcasting.osse-broadcast.url').'stream', + 'url' => config('broadcasting.osse-broadcast.stream'), ]); } } diff --git a/osse-core/config/app.php b/osse-core/config/app.php index 01d6991..90b5937 100644 --- a/osse-core/config/app.php +++ b/osse-core/config/app.php @@ -123,5 +123,6 @@ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], - 'client_url' => env('OSSE_SERVER_URL', 'http://localhost:8080') + 'domain' => env('OSSE_DOMAIN', 'osse.localhost'), + 'full_url' => 'https://' . env('OSSE_DOMAIN', 'osse.localhost'), ]; diff --git a/osse-core/config/broadcasting.php b/osse-core/config/broadcasting.php index e2bcb7e..86a797d 100644 --- a/osse-core/config/broadcasting.php +++ b/osse-core/config/broadcasting.php @@ -86,6 +86,7 @@ ], 'osse-broadcast' => [ - 'url' => env('OSSE_BROADCAST_HOST', 'http://localhost:9003') . '/' + 'sse' => config('app.full_url') . '/broadcast/sse', + 'stream' => config('app.full_url') . '/broadcast/stream', ], ]; diff --git a/osse-core/config/cors.php b/osse-core/config/cors.php index 2ecb4e5..89ac4ae 100644 --- a/osse-core/config/cors.php +++ b/osse-core/config/cors.php @@ -19,14 +19,7 @@ 'allowed_methods' => ['*'], - 'allowed_origins' => [ - // Development angular routes - 'http://localhost:4200', - // Production Routes - 'http://' . env('OSSE_HOST', 'localhost'), - 'https://' . env('OSSE_HOST', 'localhost'), - env('OSSE_URL_SERVER', "http://localhost") - ], + 'allowed_origins' => [config('app.domain')], 'allowed_origins_patterns' => [], @@ -37,5 +30,4 @@ 'max_age' => 600, 'supports_credentials' => true, - ]; diff --git a/osse-core/config/sanctum.php b/osse-core/config/sanctum.php index f5b60ff..490b97a 100644 --- a/osse-core/config/sanctum.php +++ b/osse-core/config/sanctum.php @@ -17,12 +17,7 @@ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1,' - // Production - . env('OSSE_HOST', 'localhost') . ':' . env('OSSE_SERVER_PORT', 80) . ',' - // Also Production, but without the ports (defaults). Some browsers drop the :80 or :443. - . env('OSSE_HOST', 'localhost') . ',' - . env('OSSE_HOST', 'localhost'), + config('app.domain'), Sanctum::currentApplicationUrlWithPort() ))), diff --git a/osse-core/config/session.php b/osse-core/config/session.php index e22e49b..ced46fd 100644 --- a/osse-core/config/session.php +++ b/osse-core/config/session.php @@ -156,7 +156,7 @@ | */ - 'domain' => env('OSSE_HOST') ? '.' . env('OSSE_HOST') : env('SESSION_DOMAIN', '.localhost'), + 'domain' => config('app.domain'), /* |-------------------------------------------------------------------------- @@ -169,7 +169,7 @@ | */ - 'secure' => env('SESSION_SECURE_COOKIE', false), + 'secure' => env('SESSION_SECURE_COOKIE', true), /* |-------------------------------------------------------------------------- diff --git a/osse-core/routes/api.php b/osse-core/routes/api.php index 6c04f79..fd36914 100644 --- a/osse-core/routes/api.php +++ b/osse-core/routes/api.php @@ -12,11 +12,13 @@ use Illuminate\Support\Facades\Route; Route::get('/ping', [ConfigController::class, 'ping']); -Route::get('/user', [AuthController::class, 'user'])->middleware('auth:sanctum'); -Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); -Route::post('/sse', [AuthController::class, 'authorizeSSE'])->middleware('auth:sanctum'); +// Sanctum protected (authed users only) Route::middleware('auth:sanctum')->group(function () { + Route::get('/user', [AuthController::class, 'user']); + Route::post('/logout', [AuthController::class, 'logout']); + Route::post('/sse', [AuthController::class, 'authorizeSSE']); + Route::get('/config', [ConfigController::class, 'allSettings']); Route::post('/config', [ConfigController::class, 'storeAllSettings']); Route::get('/config/directories', [ConfigController::class, 'directories']); diff --git a/osse-web/src/app/albums/albums.component.ts b/osse-web/src/app/albums/albums.component.ts index 7e5878d..c8ad547 100644 --- a/osse-web/src/app/albums/albums.component.ts +++ b/osse-web/src/app/albums/albums.component.ts @@ -20,7 +20,7 @@ import { ToastService } from '../toast-container/toast.service'; export class AlbumsComponent implements OnInit { albums: WritableSignal = signal([]); filteredAlbums: WritableSignal = signal([]); - coverUrlBase: WritableSignal = signal(this.configService.get('apiURL') + "api/tracks/ID/cover"); + coverUrlBase: WritableSignal = signal(this.configService.get('apiURL') + "tracks/ID/cover"); loading: WritableSignal = signal(true); search = mdiSearchWeb; diff --git a/osse-web/src/app/albums/view/view.component.ts b/osse-web/src/app/albums/view/view.component.ts index 7190278..8d456c2 100644 --- a/osse-web/src/app/albums/view/view.component.ts +++ b/osse-web/src/app/albums/view/view.component.ts @@ -176,7 +176,7 @@ export class ViewComponent implements OnInit, AfterViewInit, OnDestroy { ngOnInit(): void { this.album = signal(this.activatedRoute.snapshot.data['album']); this.filteredTracks.set(this.album().tracks); - this.bg.set(this.configService.get('apiURL') + "api/tracks/" + (this.album().tracks[0]?.id ?? -1) + '/cover') + this.bg.set(this.configService.get('apiURL') + "tracks/" + (this.album().tracks[0]?.id ?? -1) + '/cover') this.backgroundImageService.setBG(this.bg()); diff --git a/osse-web/src/app/login/login.component.html b/osse-web/src/app/login/login.component.html index cf24986..147926b 100644 --- a/osse-web/src/app/login/login.component.html +++ b/osse-web/src/app/login/login.component.html @@ -4,35 +4,6 @@

    Welcome to the Osse music server!

    - @if (showConnectionInputs()) { -

    To begin, enter the server URL. This should include the host and port. The default URL has - been filled in for you.

    -
    -
    - - -
    - -
    - - -
    - -
    - -
    -
    - } - @if (serverFound()) {

    Please login.

    @@ -60,11 +31,11 @@ } - @if (!serverFound() && !showConnectionInputs()) { + @if (!serverFound()) {

    Connecting to API...

    } - @if (serverFound() || showConnectionInputs()) { + @if (serverFound()) {

    Don't have an account yet? Create One

    } diff --git a/osse-web/src/app/login/login.component.ts b/osse-web/src/app/login/login.component.ts index 7337a14..2b8c8e3 100644 --- a/osse-web/src/app/login/login.component.ts +++ b/osse-web/src/app/login/login.component.ts @@ -17,9 +17,6 @@ export class LoginComponent implements OnInit { public username: string = ''; public password: string = ''; public serverFound: WritableSignal = signal(false); - public url: WritableSignal = signal(window.location.hostname + ':8000'); - public protocol: WritableSignal = signal('http://'); - public showConnectionInputs: WritableSignal = signal(false); public waitingForResponse = signal(false); constructor( @@ -29,28 +26,6 @@ export class LoginComponent implements OnInit { private authService: AuthService ) { } - public async saveURL() { - if (!this.url().endsWith("/")) { - this.url.update(u => u + "/"); - } - - // Check if the server URL is right. - try { - this.waitingForResponse.set(true); - await fetch(this.protocol().concat(this.url()) + 'api/ping', { - credentials: 'include' - }); - // Save the URL - this.configService.save("apiURL", this.protocol().concat(this.url())); - this.notificationService.info("URL saved as " + this.configService.get("apiURL")); - this.serverFound.set(true); - } catch (e) { - this.notificationService.error('Failed to reach server. Confirm that the URL is correct.'); - } finally { - this.waitingForResponse.set(false); - } - } - public async login() { if (this.username.length == 0 || this.password.length == 0) { this.notificationService.error('You must enter a username and password.'); @@ -81,14 +56,13 @@ export class LoginComponent implements OnInit { async ngOnInit() { // Try to login with the default URL. try { - await fetch(this.configService.get('apiURL') + 'api/ping', { + await fetch(this.configService.get('apiURL') + 'ping', { credentials: 'include' }); this.serverFound.set(true); } catch (e) { // This should only happen in dev. If it fails, show the server URL inputs. this.notificationService.error('Failed to autodetect server URL. Please enter it.'); - this.showConnectionInputs.set(true); } } } diff --git a/osse-web/src/app/registration/registration.component.html b/osse-web/src/app/registration/registration.component.html index efe3b64..cb0b5c9 100644 --- a/osse-web/src/app/registration/registration.component.html +++ b/osse-web/src/app/registration/registration.component.html @@ -4,34 +4,6 @@

    Welcome to the Osse music server!

    - @if (showConnectionInputs()) { -
    -

    To begin, enter the server URL. This should include the host and port. The default URL has - been filled in for you.

    -
    - - -
    - -
    - - -
    - -
    - - - @if (waitingForResponse()) { - - } -
    -
    - } - @if (serverFound()) {

    Please create your account.

    @@ -57,11 +29,11 @@ } - @if (!serverFound() && !showConnectionInputs()) { + @if (!serverFound()) {

    Connecting to API...

    } - @if (serverFound() || showConnectionInputs()) { + @if (serverFound()) {

    Already have an account? Login

    } diff --git a/osse-web/src/app/registration/registration.component.ts b/osse-web/src/app/registration/registration.component.ts index c54b94d..93a8b84 100644 --- a/osse-web/src/app/registration/registration.component.ts +++ b/osse-web/src/app/registration/registration.component.ts @@ -17,9 +17,6 @@ export class RegistrationComponent implements OnInit { public username: string = ''; public password: string = ''; public serverFound: WritableSignal = signal(false); - public url: WritableSignal = signal(window.location.hostname + ':8000'); - public protocol: WritableSignal = signal('http://'); - public showConnectionInputs: WritableSignal = signal(false); public waitingForResponse = signal(false); constructor( @@ -29,26 +26,6 @@ export class RegistrationComponent implements OnInit { private authService: AuthService ) { } - public async saveURL() { - if (!this.url().endsWith("/")) { - this.url.update(u => u + "/"); - } - - // Check if the server URL is right. - try { - this.waitingForResponse.set(true); - await fetch(this.protocol().concat(this.url()) + 'api/ping'); - // Save the URL - this.configService.save("apiURL", this.protocol().concat(this.url())); - this.notificationService.info("URL saved as " + this.configService.get("apiURL")); - this.serverFound.set(true); - } catch (e) { - this.notificationService.error('Failed to reach server. Confirm that the URL is correct and that the server is running.'); - } finally { - this.waitingForResponse.set(false); - } - } - public async register() { if (this.username.length == 0 || this.password.length == 0) { this.notificationService.error('You must enter a username and password.'); @@ -93,7 +70,6 @@ export class RegistrationComponent implements OnInit { } catch (e) { // This should only happen in dev. If it fails, show the server URL inputs. this.notificationService.error('Failed to autodetect server URL. Please enter it.'); - this.showConnectionInputs.set(true); } } } diff --git a/osse-web/src/app/shared/services/api.service.ts b/osse-web/src/app/shared/services/api.service.ts index b1a2f3e..72d3d24 100644 --- a/osse-web/src/app/shared/services/api.service.ts +++ b/osse-web/src/app/shared/services/api.service.ts @@ -1,29 +1,14 @@ import { Injectable } from '@angular/core'; -import { ConfigService } from './config/config.service'; -import { Track } from './track/track'; -import { OsseTrack } from './track/osse-track'; import { Artist } from './artist/artist'; -import { Album } from './album/Album'; import { fetcher } from '../util/fetcher'; @Injectable({ providedIn: 'root' }) export class ApiService { + constructor() { } - constructor(private configService: ConfigService) { } - - public async getAllTracks(): Promise { - try { - let request = await fetch(`${this.configService.get('apiURL')}tracks/all`); - let response = await request.json(); - - return response.map((track: OsseTrack) => new Track(track)) - } catch (e) { - return []; - } - } - + // TODO: Move this to an artist service. public async getArtist(id: number): Promise { let request = await fetcher(`artists/${id}`); if (request.ok) { @@ -33,18 +18,4 @@ export class ApiService { return null; } } - - public async getAlbumWithTracks(id: number): Promise { - let request = await fetch(`${this.configService.get('apiURL')}albums/${id}/tracks`); - if (request.ok) { - let album = await request.json(); - return new Album(album); - } else { - return null; - } - } - - public get url() { - return this.configService.get('apiURL') + '/api/'; - } } diff --git a/osse-web/src/app/shared/services/config/config.service.ts b/osse-web/src/app/shared/services/config/config.service.ts index d807239..64ccfac 100644 --- a/osse-web/src/app/shared/services/config/config.service.ts +++ b/osse-web/src/app/shared/services/config/config.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { environment } from '../../../../environments/environment'; import { OsseConfig } from './config'; -import { getCookie } from '../../util/fetcher'; @Injectable({ providedIn: 'root' @@ -10,11 +9,9 @@ export class ConfigService { private config!: OsseConfig; constructor() { - this.initConfig(); - // Get the ENV and populate any variables. Localstorage has priority this.config = { - apiURL: localStorage.getItem('apiURL') ?? environment.apiURL, + apiURL: '/api/', version: environment.version, showCoverBackgrounds: Boolean(localStorage.getItem('showCoverBackgrounds') ?? environment.showCoverBackgrounds), showVisualizer: Boolean(localStorage.getItem('showVisualizer') ?? environment.showVisualizer), @@ -57,17 +54,4 @@ export class ConfigService { public overrideConfig(conf: Partial) { this.config = { ...this.config, ...conf }; } - - private initConfig() { - // If the user hasn't set an API URl, check what the server says the URL is. Fallback to that, or the env. - let userApiURL = localStorage.getItem('apiURL'); - if (!userApiURL) { - let serverSaysApiUrl = getCookie('API_URL'); - if (serverSaysApiUrl) { - localStorage.setItem('apiURL', serverSaysApiUrl.endsWith('/') ? serverSaysApiUrl : serverSaysApiUrl + '/'); - } else { - localStorage.setItem('apiURL', environment.apiURL); - } - } - } } diff --git a/osse-web/src/app/shared/services/track/track.ts b/osse-web/src/app/shared/services/track/track.ts index 201601e..28452d2 100644 --- a/osse-web/src/app/shared/services/track/track.ts +++ b/osse-web/src/app/shared/services/track/track.ts @@ -166,6 +166,6 @@ export class Track { } get coverURL() { - return this.configService.get('apiURL') + "api/cover-art/" + this.track.cover_art_id; + return this.configService.get('apiURL') + "cover-art/" + this.track.cover_art_id; } } diff --git a/osse-web/src/app/shared/util/fetcher.ts b/osse-web/src/app/shared/util/fetcher.ts index 6ba9503..ed29d1e 100644 --- a/osse-web/src/app/shared/util/fetcher.ts +++ b/osse-web/src/app/shared/util/fetcher.ts @@ -16,7 +16,7 @@ export async function fetcher(url: string, args: Partial = { method headers.append('Content-Type', 'application/json'); headers.append('Accept', 'application/json'); - return fetch((args.rootURL ?? LocatorService.injector.get(ConfigService).get('apiURL') + 'api/') + url, { + return fetch((args.rootURL ?? LocatorService.injector.get(ConfigService).get('apiURL')) + url, { method: args.method, headers: headers, body: args.body, diff --git a/osse-web/src/environments/environment.prod.ts b/osse-web/src/environments/environment.prod.ts index a43419a..3a0a9fc 100644 --- a/osse-web/src/environments/environment.prod.ts +++ b/osse-web/src/environments/environment.prod.ts @@ -1,6 +1,5 @@ export const environment = { version: '0.0.1', - apiURL: 'http://localhost:9000/', showCoverBackgrounds: true, showVisualizer: true, visualizerSamples: 1, diff --git a/osse-web/src/environments/environment.ts b/osse-web/src/environments/environment.ts index c123de6..d0601a5 100644 --- a/osse-web/src/environments/environment.ts +++ b/osse-web/src/environments/environment.ts @@ -1,6 +1,5 @@ export const environment = { version: 'dev', - apiURL: 'http://localhost:8000/', showCoverBackgrounds: true, showVisualizer: true, visualizerSamples: 1, diff --git a/osse.sh b/osse.sh new file mode 100755 index 0000000..e860fa4 --- /dev/null +++ b/osse.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +echo -e "\nšŸŽµ Osse Music Server\n" + +if [ "$EUID" -ne 0 ]; then + echo "šŸ” Osse needs elevated privileges to bind to ports 80/443." + echo "You may be prompted for your password." + sudo -v +fi + +if [ ! -f .env ]; then + echo "āŒ Missing .env file" + exit 1 +fi + +set -a +source .env +set +a + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "āŒ $1 is required" + echo "šŸ‘‰ $2" + exit 1 + } +} + +require frankenphp "https://frankenphp.dev/docs/#getting-started" +require php "https://www.php.net/downloads" +require node "https://nodejs.org" +require go "https://go.dev/dl" + +PNPM=$(command -v pnpm || command -v npm) +[ -z "$PNPM" ] && echo "āŒ pnpm or npm required" && exit 1 + +PIDS=() + +cleanup() { + echo "šŸ›‘ Stopping Osse" + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done +} +trap cleanup EXIT INT TERM + +generate_caddy() { + echo "🧩 Generating Caddyfile" + rm -f Caddyfile + envsubst < deployment/Caddyfile.template > Caddyfile +} + +start_broadcast() { + echo "šŸ“” Starting broadcast server" + (cd osse-broadcast && go run .) & + PIDS+=($!) +} + +start_frontend_dev() { + echo "🌐 Starting frontend dev server" + # (cd osse-web && $PNPM run start) & + # PIDS+=($!) +} + +build_frontend() { + echo "šŸ—ļø Building frontend" + (cd osse-web && $PNPM run build) +} + +build_broadcast() { + echo 'šŸ—ļø Building broadcast server' + (cd osse-broadcast && go mod tidy && go build -o bin/osse-broadcast) + echo 'Finished building broadcast server' +} + +start_frankenphp() { + echo "šŸš€ Starting Frankenphp (Web Server)" + sudo frankenphp run --config Caddyfile & + PIDS+=($!) +} + +case "$1" in + dev) + generate_caddy + start_broadcast + start_frontend_dev + start_frankenphp + wait + ;; + run) + generate_caddy + start_broadcast + start_frankenphp + wait + ;; + build) + build_frontend + build_broadcast + ;; + *) + echo "Usage: ./osse {dev|run|build}" + exit 1 + ;; +esac + From 364f8f8e1edca011792d2142ef6b8362b1f61e27 Mon Sep 17 00:00:00 2001 From: aMytho <58316242+aMytho@users.noreply.github.com> Date: Fri, 2 Jan 2026 21:54:33 -0600 Subject: [PATCH 4/8] Update laravel env in script, add php cli to script --- .env.example | 11 ++++- deployment/Caddyfile.template | 6 +-- osse-core/.env.example | 12 ------ osse-web/package.json | 2 +- osse.sh | 79 +++++++++++++++++++++++++++++++---- 5 files changed, 84 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 8ec2e46..1affdf3 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,19 @@ OSSE_DOMAIN=osse.localhost -OSSE_ENV=dev +# Can be local or prod +OSSE_ENV=prod # Internal ports (never exposed publicly, but they need to not be in use) OSSE_API_PORT=8000 OSSE_BROADCAST_PORT=8090 +# 4200 is also in use if in development mode. # Redis instance (include the domain and the port). Valkey works fine too! OSSE_REDIS_HOST="localhost:6379" +# The paths to scan for music. See examples below. Only absolute paths are supported (no ~ or env vars). Separate directories with comma. +export OSSE_DIRECTORIES="" +# export OSSE_DIRECTORIES="/home/me/Music,/mnt/server1/files" +# If true, allow new accounts to be created. Once you make your account, set this to false. +export OSSE_ALLOW_REGISTRATION=true + # Storage OSSE_DATA_DIR=/var/lib/osse + diff --git a/deployment/Caddyfile.template b/deployment/Caddyfile.template index 3aaf718..2d6d76f 100644 --- a/deployment/Caddyfile.template +++ b/deployment/Caddyfile.template @@ -40,11 +40,11 @@ https://${OSSE_DOMAIN} { reverse_proxy 127.0.0.1:${OSSE_BROADCAST_PORT} } - # Web frontend + # Web frontend (this is replaced in the root osse script as it changes based on env) handle { root * osse-web/dist/osse-web/browser/ - try_files {path} /index.html - file_server + + WEB_FRONTEND_TEMPLATE } encode zstd br gzip diff --git a/osse-core/.env.example b/osse-core/.env.example index dded0fa..a108375 100644 --- a/osse-core/.env.example +++ b/osse-core/.env.example @@ -1,14 +1,3 @@ -# For local development only - uncomment these vars -# OSSE_PROTOCOL="http" -# OSSE_HOST="localhost" -# OSSE_SERVER_PORT="4200" -# OSSE_API_PORT="8000" -# OSSE_BROADCAST_PORT="9003" -# OSSE_BROADCAST_INTERNAL_PORT="9004" -# OSSE_REDIS_PORT="9005" -# OSSE_SERVER_URL="localhost:4200" -# OSSE_DIRECTORIES="~/Music" -# APP_NAME=osse APP_ENV=local APP_KEY= @@ -76,4 +65,3 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" -OSSE_ALLOW_REGISTRATION=true diff --git a/osse-web/package.json b/osse-web/package.json index 4fe659d..c785ee0 100644 --- a/osse-web/package.json +++ b/osse-web/package.json @@ -40,4 +40,4 @@ "typescript": "~5.9.3", "vitest": "^4.0.16" } -} \ No newline at end of file +} diff --git a/osse.sh b/osse.sh index e860fa4..153eee0 100755 --- a/osse.sh +++ b/osse.sh @@ -3,13 +3,14 @@ echo -e "\nšŸŽµ Osse Music Server\n" if [ "$EUID" -ne 0 ]; then echo "šŸ” Osse needs elevated privileges to bind to ports 80/443." - echo "You may be prompted for your password." + echo "You may be prompted for your (sudo) password." sudo -v fi if [ ! -f .env ]; then - echo "āŒ Missing .env file" - exit 1 + echo "āŒ Missing .env file. A file will be copied from the example for you." + cp .env.example .env + echo "File copied!" fi set -a @@ -19,13 +20,12 @@ set +a require() { command -v "$1" >/dev/null 2>&1 || { echo "āŒ $1 is required" - echo "šŸ‘‰ $2" + echo "šŸ‘‰ You can install it here: $2" exit 1 } } require frankenphp "https://frankenphp.dev/docs/#getting-started" -require php "https://www.php.net/downloads" require node "https://nodejs.org" require go "https://go.dev/dl" @@ -46,6 +46,14 @@ generate_caddy() { echo "🧩 Generating Caddyfile" rm -f Caddyfile envsubst < deployment/Caddyfile.template > Caddyfile + + # Use angular dev server in dev, use build files in prod. + if [ "$OSSE_ENV" = "dev" ]; then + REPLACE="reverse_proxy http://localhost:4200" + else + REPLACE="try_files {path} /index.html\nfile_server" + fi + sed -i "s+WEB_FRONTEND_TEMPLATE+$REPLACE+" Caddyfile } start_broadcast() { @@ -56,13 +64,51 @@ start_broadcast() { start_frontend_dev() { echo "🌐 Starting frontend dev server" - # (cd osse-web && $PNPM run start) & - # PIDS+=($!) + (cd osse-web && $PNPM run start) & + PIDS+=($!) +} + +copy_api_env() { + echo "šŸ—ļø Building API" + + # If the file exists, get the encryption key to add to the new file + OSSE_ENCRYPTION_KEY="" + if [ -e osse-core/.env ]; then + echo "ENV Exists. Copying APP_KEY to new file..." + OSSE_ENCRYPTION_KEY="$(grep '^APP_KEY' osse-core/.env)" + fi + + # Remove the old file if one exists + rm -f osse-core/.env + + # Copy the example .env + cp osse-core/.env.example osse-core/.env + + # Replace the app key + if [ -z "$OSSE_ENCRYPTION_KEY" ]; then + (cd osse-core && frankenphp php-cli artisan key:generate) + else + sed -i "s+APP_KEY.*+$OSSE_ENCRYPTION_KEY+" osse-core/.env + sed -i "s+APP_ENV.*+APP_ENV=$OSSE_ENV+" osse-core/.env + fi + + # Add user vars to end of api env + echo -e "OSSE_DIRECTORIES=\"$OSSE_DIRECTORIES\"\nALLOW_REGISTRATION=\"$OSSE_ALLOW_REGISTRATION\"" >> osse-core/.env + echo "Osse .env file generated" +} + +optimize_api() { + (cd osse-core && frankenphp php-cli artisan config:cache) + (cd osse-core && frankenphp php-cli artisan config:optimize) +} + +run_api_migrations() { + (cd osse-core && frankenphp php-cli artisan migrate) } build_frontend() { echo "šŸ—ļø Building frontend" - (cd osse-web && $PNPM run build) + (cd osse-web && $PNPM i && $PNPM run build) } build_broadcast() { @@ -77,8 +123,15 @@ start_frankenphp() { PIDS+=($!) } +run_broadcast() { + echo "šŸ“” Starting broadcast server" + (cd osse-broadcast && ./bin/osse-broadcast) & + PIDS+=($!) +} + case "$1" in dev) + copy_api_env generate_caddy start_broadcast start_frontend_dev @@ -86,15 +139,23 @@ case "$1" in wait ;; run) + run_api_migrations + optimize_api generate_caddy - start_broadcast + run_broadcast start_frankenphp wait ;; build) + run_api_migrations build_frontend build_broadcast + copy_api_env ;; + # Access php cli + php-cli) + cd osse-core && frankenphp php-cli "${@:2}" + ;; *) echo "Usage: ./osse {dev|run|build}" exit 1 From 618d8081a573f909f8395c6c7d13baed4035e06f Mon Sep 17 00:00:00 2001 From: aMytho <58316242+aMytho@users.noreply.github.com> Date: Sat, 3 Jan 2026 11:00:43 -0600 Subject: [PATCH 5/8] Update readme for monorepo --- osse-core/README.md => README.md | 6 ++-- osse-web/README.md | 54 -------------------------------- 2 files changed, 2 insertions(+), 58 deletions(-) rename osse-core/README.md => README.md (76%) delete mode 100644 osse-web/README.md diff --git a/osse-core/README.md b/README.md similarity index 76% rename from osse-core/README.md rename to README.md index 30bd6fc..80c471b 100644 --- a/osse-core/README.md +++ b/README.md @@ -17,12 +17,10 @@ Osse is a free and open source music player and server. This repository is the * > Interested in helping us test? Use the below instructions for an installation. -Docker is the recommended method of installation. You can also use Podman. Alternatively, you can run Osse on your local machine by installing it's dependencies. + Systemd files are available in this repo (outdated) -Devices with constrained resources should use the manual installation for performance reasons. Systemd files are available in this repo. - -- [Docker/Podman installation](https://github.com/aMytho/osse/wiki/Installation-(Docker-Podman)) - [Manual Installation](https://github.com/aMytho/osse/wiki/Installation-(Manual-System)) +- [Docker/Podman installation (outdated)](https://github.com/aMytho/osse/wiki/Installation-(Docker-Podman)) ## Providing Feedback diff --git a/osse-web/README.md b/osse-web/README.md deleted file mode 100644 index a683787..0000000 --- a/osse-web/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Osse-Web - -Osse is a free and open source music player and server. This repository is the web frontend. - -## Features - -> Osse is in early development. There will be bugs and unexpected behavior. Some features are not yet complete. It is safe to use on your library, but it will need some time before it can be your main music player. - -- Supports most music formats (MP3, Ogg/Opus, Flac, WAV). -- Support reading tags for library generation. -- Album & Playlist support. -- No Tracking/Telemetry/Data collection. -- Simplicity. Install it and it will just work. -- Support for Linux/Mac/Windows (Mac/Windows need Docker or other medium). Any OS (including Android/IOS) can use the web frontend. - -## Installation - -Both the server and the web frontend (this project) must be installed. - -> When v1 releases, we will provide a standalone installer/executable to simplify this process. We will also provide docker images. Currently, you must manually install the projects and their dependencies. - -You will need the following tools installed: - -- Git https://git-scm.com/downloads -- PHP 8.4 `/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.4)"` -- NodeJS v22 https://nodejs.org/en -- PNPM (optional, preferred over NPM) https://pnpm.io/installation - -Clone this repository and the server. - -``` -git clone https://github.com/amytho/osse -cd -git clone https://github.com/amytho/osse-web -``` - -Start the web frontend and the php backend. - -``` -cd osse -composer run dev -``` - -In another terminal window: -``` -cd osse-web -pnpm start -``` - -Open the web frontend and login. http://localhost:4200 - -The default username is `osse` and the default password is `cassidor`. - -You should edit the .env file in the server and add your music directory to it. This is located at the bottom of the file. From 1dba1dff0873323df83e4a6a6afcdc7ea81923a3 Mon Sep 17 00:00:00 2001 From: aMytho <58316242+aMytho@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:51:35 -0600 Subject: [PATCH 6/8] Run queue, use php init for queue settings, use storage dir --- .env.example | 5 ++--- deployment/php.ini | 4 ++++ osse.sh | 31 ++++++++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 deployment/php.ini diff --git a/.env.example b/.env.example index 1affdf3..39bd7de 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,5 @@ export OSSE_DIRECTORIES="" # If true, allow new accounts to be created. Once you make your account, set this to false. export OSSE_ALLOW_REGISTRATION=true -# Storage -OSSE_DATA_DIR=/var/lib/osse - +# Storage (config, cache, database). Path will be created if not present. +LARAVEL_STORAGE_PATH="~/.config/osse-2" diff --git a/deployment/php.ini b/deployment/php.ini new file mode 100644 index 0000000..59e6789 --- /dev/null +++ b/deployment/php.ini @@ -0,0 +1,4 @@ +; deployment/php.ini +memory_limit = 2G +max_execution_time = 0 +opcache.enable_cli = 1 diff --git a/osse.sh b/osse.sh index 153eee0..4444356 100755 --- a/osse.sh +++ b/osse.sh @@ -2,7 +2,7 @@ echo -e "\nšŸŽµ Osse Music Server\n" if [ "$EUID" -ne 0 ]; then - echo "šŸ” Osse needs elevated privileges to bind to ports 80/443." + echo "šŸ” Osse needs elevated privileges to bind to ports 80/443 and to give the web server write access to osse config." echo "You may be prompted for your (sudo) password." sudo -v fi @@ -17,6 +17,22 @@ set -a source .env set +a +# Make sure osse config dir exists +eval "LARAVEL_STORAGE_PATH=$LARAVEL_STORAGE_PATH" +DB_DATABASE="$LARAVEL_STORAGE_PATH/database.sqlite" +mkdir $LARAVEL_STORAGE_PATH -p +mkdir "$LARAVEL_STORAGE_PATH"/storage -p +mkdir "$LARAVEL_STORAGE_PATH"/logs -p +touch "$LARAVEL_STORAGE_PATH"/logs/laravel.log +mkdir "$LARAVEL_STORAGE_PATH"/framework/cache -p +mkdir "$LARAVEL_STORAGE_PATH"/framework/sessions -p +mkdir "$LARAVEL_STORAGE_PATH"/framework/views -p +touch "$DB_DATABASE" + +# Make them read/writable +# TODO: Figure out which user needs what access. +sudo chmod -R 755 "$LARAVEL_STORAGE_PATH" + require() { command -v "$1" >/dev/null 2>&1 || { echo "āŒ $1 is required" @@ -93,7 +109,7 @@ copy_api_env() { fi # Add user vars to end of api env - echo -e "OSSE_DIRECTORIES=\"$OSSE_DIRECTORIES\"\nALLOW_REGISTRATION=\"$OSSE_ALLOW_REGISTRATION\"" >> osse-core/.env + echo -e "OSSE_DIRECTORIES=\"$OSSE_DIRECTORIES\"\nALLOW_REGISTRATION=\"$OSSE_ALLOW_REGISTRATION\"\nLARAVEL_STORAGE_PATH=\"$LARAVEL_STORAGE_PATH\"\nDB_DATABASE=\"$DB_DATABASE\"" >> osse-core/.env echo "Osse .env file generated" } @@ -129,13 +145,22 @@ run_broadcast() { PIDS+=($!) } +run_api_queue() { + # Osse queue needs a special ini file to increase memory limit. + echo "šŸš€ Starting Osse Queue" + (PHP_INI_SCAN_DIR="$PWD/deployment" cd osse-core && frankenphp php-cli artisan queue:work --timeout=0 --memory=2048) + PIDS+=($!) +} + case "$1" in dev) copy_api_env generate_caddy + run_api_migrations start_broadcast start_frontend_dev start_frankenphp + run_api_queue wait ;; run) @@ -144,10 +169,10 @@ case "$1" in generate_caddy run_broadcast start_frankenphp + run_api_queue wait ;; build) - run_api_migrations build_frontend build_broadcast copy_api_env From 51be5bec45ea3df521c0a7259c31d45607613dd7 Mon Sep 17 00:00:00 2001 From: aMytho <58316242+aMytho@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:39:44 -0600 Subject: [PATCH 7/8] Try to fix pipeline --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2dcc01..f1e1ac7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,8 @@ jobs: with: php-version: '8.4' - uses: actions/checkout@v4 + - name: Move to osse-core + run: cd osse-core - name: Copy .env run: php -r "file_exists('.env') || copy('.env.example', '.env');" - name: Install Dependencies From 461b101ad20ff975c6032083fb658e459cbc650b Mon Sep 17 00:00:00 2001 From: aMytho <58316242+aMytho@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:46:20 -0600 Subject: [PATCH 8/8] Use testing env --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f1e1ac7..abf3f4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: - name: Move to osse-core run: cd osse-core - name: Copy .env - run: php -r "file_exists('.env') || copy('.env.example', '.env');" + run: php -r "file_exists('.env') || copy('.env.testing', '.env');" - name: Install Dependencies run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - name: Generate key