refactor: Prototype of tanstack-query (#34)
* move getApiUrl to api folder * adjust imports * tanstack-query example with homeData * small adjustments * fix key collision * new MusicSource persistent mechanism example * add error handling & set sveltekit to SPA mode * remove unnecessary ssr test
This commit is contained in:
+1
-1
@@ -35,7 +35,7 @@ Frontend:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
cp env.dev.example .env
|
cp env.development.example .env.development
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
musicseerr:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: musicseerr-dev
|
||||||
|
environment:
|
||||||
|
- PUID=1000 # User ID — run `id` on your host to find yours
|
||||||
|
- PGID=1000 # Group ID — run `id` on your host to find yours
|
||||||
|
# Unraid: use 99/100 (nobody:users) unless you changed Unraid's defaults.
|
||||||
|
# If using Docker --user, PUID/PGID are ignored (user mapping is external).
|
||||||
|
- PORT=8688 # Internal port (must match the right side of "ports" below)
|
||||||
|
- TZ=Etc/UTC # Timezone — e.g. America/New_York, Europe/London
|
||||||
|
ports:
|
||||||
|
- "8689:8688" # <host-port>:<container-port> — change the left side to remap
|
||||||
|
volumes:
|
||||||
|
- config:/app/config
|
||||||
|
- cache:/app/cache
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8688/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 15s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
config:
|
||||||
|
cache:
|
||||||
@@ -45,5 +45,25 @@ export default defineConfig(
|
|||||||
rules: {
|
rules: {
|
||||||
'svelte/no-navigation-without-resolve': 'off'
|
'svelte/no-navigation-without-resolve': 'off'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
// Ensure not to call queryClient.setQueriesData or queryClient.setQueryData directly, as this will bypass the persister and lead to data loss on page refresh.
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"CallExpression[callee.object.name='queryClient'][callee.property.name='setQueriesData']",
|
||||||
|
message:
|
||||||
|
"Direct use of 'queryClient.setQueriesData' is forbidden. Please use the 'setQueriesDataWithPersister' function instead."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"CallExpression[callee.object.name='queryClient'][callee.property.name='setQueryData']",
|
||||||
|
message:
|
||||||
|
"Direct use of 'queryClient.setQueryData' is forbidden. Please use the 'setQueryDataWithPersister' function instead."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev --open",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tailwind": "tailwindcss -i ./src/app.css -o ./static/tailwind.css --watch",
|
"tailwind": "tailwindcss -i ./src/app.css -o ./static/tailwind.css --watch",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check:ci": "pnpm check && pnpm format:check && pnpm lint",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
@@ -47,6 +48,11 @@
|
|||||||
"vitest-browser-svelte": "^1.1.0"
|
"vitest-browser-svelte": "^1.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-svelte": "^0.575.0"
|
"@tanstack/svelte-query": "^6.1.13",
|
||||||
|
"@tanstack/svelte-query-devtools": "^6.1.13",
|
||||||
|
"@tanstack/svelte-query-persist-client": "^6.1.13",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
|
"lucide-svelte": "^0.575.0",
|
||||||
|
"runed": "^0.37.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+93
@@ -8,9 +8,24 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@tanstack/svelte-query':
|
||||||
|
specifier: ^6.1.13
|
||||||
|
version: 6.1.13(svelte@5.55.1)
|
||||||
|
'@tanstack/svelte-query-devtools':
|
||||||
|
specifier: ^6.1.13
|
||||||
|
version: 6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1)
|
||||||
|
'@tanstack/svelte-query-persist-client':
|
||||||
|
specifier: ^6.1.13
|
||||||
|
version: 6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1)
|
||||||
|
idb-keyval:
|
||||||
|
specifier: ^6.2.2
|
||||||
|
version: 6.2.2
|
||||||
lucide-svelte:
|
lucide-svelte:
|
||||||
specifier: ^0.575.0
|
specifier: ^0.575.0
|
||||||
version: 0.575.0(svelte@5.55.1)
|
version: 0.575.0(svelte@5.55.1)
|
||||||
|
runed:
|
||||||
|
specifier: ^0.37.1
|
||||||
|
version: 0.37.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
@@ -721,6 +736,32 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.2.2':
|
'@tailwindcss/postcss@4.2.2':
|
||||||
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
|
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.96.2':
|
||||||
|
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
|
||||||
|
|
||||||
|
'@tanstack/query-devtools@5.96.2':
|
||||||
|
resolution: {integrity: sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g==}
|
||||||
|
|
||||||
|
'@tanstack/query-persist-client-core@5.96.2':
|
||||||
|
resolution: {integrity: sha512-BYsP8folbvxzZsNnWJxSenEAdepGNfv809150U78D84yt/THi33EwfUCcdKWFbma5XKwlaFQGWMJKeWnVJ6GVA==}
|
||||||
|
|
||||||
|
'@tanstack/svelte-query-devtools@6.1.13':
|
||||||
|
resolution: {integrity: sha512-55gMVoPRh7BcwQTogSt8pPrSsQ6lWnfJct3Yos1t2tAZ1YL9KbLEa6X4/iVfdPmxKO+czdDN6GSkMIB05vVKKg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tanstack/svelte-query': ^6.1.13
|
||||||
|
svelte: ^5.25.0
|
||||||
|
|
||||||
|
'@tanstack/svelte-query-persist-client@6.1.13':
|
||||||
|
resolution: {integrity: sha512-YJIx61T3857k4o206S1y8dNruzEGL1qwN63KoE+0L8juw+4rbsN2Jzq3dCADwio5JALPW4GJa12f26C4ufujAA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tanstack/svelte-query': ^6.1.13
|
||||||
|
svelte: ^5.25.0
|
||||||
|
|
||||||
|
'@tanstack/svelte-query@6.1.13':
|
||||||
|
resolution: {integrity: sha512-A/BB9BuRoPSEJZqVUU5sqcAgi13XdfldlJtd8XmbwAXr/fxEMfpSEMYmIxLmUb/s9EVYRJi3bew9OjZddkx0cg==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.25.0
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1156,6 +1197,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
idb-keyval@6.2.2:
|
||||||
|
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -1477,6 +1521,18 @@ packages:
|
|||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
runed@0.37.1:
|
||||||
|
resolution: {integrity: sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@sveltejs/kit': ^2.21.0
|
||||||
|
svelte: ^5.7.0
|
||||||
|
zod: ^4.1.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@sveltejs/kit':
|
||||||
|
optional: true
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
sade@1.8.1:
|
sade@1.8.1:
|
||||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2177,6 +2233,32 @@ snapshots:
|
|||||||
postcss: 8.5.8
|
postcss: 8.5.8
|
||||||
tailwindcss: 4.2.2
|
tailwindcss: 4.2.2
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.96.2': {}
|
||||||
|
|
||||||
|
'@tanstack/query-devtools@5.96.2': {}
|
||||||
|
|
||||||
|
'@tanstack/query-persist-client-core@5.96.2':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.96.2
|
||||||
|
|
||||||
|
'@tanstack/svelte-query-devtools@6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-devtools': 5.96.2
|
||||||
|
'@tanstack/svelte-query': 6.1.13(svelte@5.55.1)
|
||||||
|
esm-env: 1.2.2
|
||||||
|
svelte: 5.55.1
|
||||||
|
|
||||||
|
'@tanstack/svelte-query-persist-client@6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-persist-client-core': 5.96.2
|
||||||
|
'@tanstack/svelte-query': 6.1.13(svelte@5.55.1)
|
||||||
|
svelte: 5.55.1
|
||||||
|
|
||||||
|
'@tanstack/svelte-query@6.1.13(svelte@5.55.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.96.2
|
||||||
|
svelte: 5.55.1
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
@@ -2661,6 +2743,8 @@ snapshots:
|
|||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
|
idb-keyval@6.2.2: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
@@ -2927,6 +3011,15 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
runed@0.37.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1):
|
||||||
|
dependencies:
|
||||||
|
dequal: 2.0.3
|
||||||
|
esm-env: 1.2.2
|
||||||
|
lz-string: 1.5.0
|
||||||
|
svelte: 5.55.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@sveltejs/kit': 2.56.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||||
|
|
||||||
sade@1.8.1:
|
sade@1.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mri: 1.2.0
|
mri: 1.2.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { pageFetch } from '$lib/utils/navigationAbort';
|
import { pageFetch } from '$lib/utils/navigationAbort';
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import ArtistMonitoringToggle from './ArtistMonitoringToggle.svelte';
|
import ArtistMonitoringToggle from './ArtistMonitoringToggle.svelte';
|
||||||
import BackButton from './BackButton.svelte';
|
import BackButton from './BackButton.svelte';
|
||||||
import HeroBackdrop from './HeroBackdrop.svelte';
|
import HeroBackdrop from './HeroBackdrop.svelte';
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artist: ArtistInfo;
|
artist: ArtistInfo;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { isValidMbid } from '$lib/utils/formatting';
|
import { isValidMbid } from '$lib/utils/formatting';
|
||||||
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
||||||
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mbid: string;
|
mbid: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
import type { BecauseYouListenTo } from '$lib/types';
|
import type { BecauseYouListenTo } from '$lib/types';
|
||||||
import HomeSection from './HomeSection.svelte';
|
import HomeSection from './HomeSection.svelte';
|
||||||
import HeroBackdrop from './HeroBackdrop.svelte';
|
import HeroBackdrop from './HeroBackdrop.svelte';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
||||||
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
import { Search } from 'lucide-svelte';
|
import { Search } from 'lucide-svelte';
|
||||||
import type { SuggestResult } from '$lib/types';
|
import type { SuggestResult } from '$lib/types';
|
||||||
import { API } from '$lib/constants';
|
import { API } from '$lib/constants';
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type MusicSource } from '$lib/stores/musicSource';
|
||||||
|
import { integrationStore } from '$lib/stores/integration';
|
||||||
|
import { fromStore } from 'svelte/store';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentSource: MusicSource;
|
||||||
|
onSourceChange?: (source: MusicSource) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { currentSource, onSourceChange }: Props = $props();
|
||||||
|
|
||||||
|
const integrationState = fromStore(integrationStore);
|
||||||
|
|
||||||
|
let switching = $state(false);
|
||||||
|
|
||||||
|
let lbEnabled = $derived(integrationState.current.listenbrainz);
|
||||||
|
let lfmEnabled = $derived(integrationState.current.lastfm);
|
||||||
|
let showSwitcher = $derived(lbEnabled && lfmEnabled);
|
||||||
|
|
||||||
|
async function handleSwitch(source: MusicSource) {
|
||||||
|
if (source === currentSource || switching) return;
|
||||||
|
switching = true;
|
||||||
|
onSourceChange?.(source);
|
||||||
|
switching = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showSwitcher}
|
||||||
|
<div class="join">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm join-item {currentSource === 'listenbrainz' ? 'btn-primary' : 'btn-ghost'}"
|
||||||
|
disabled={switching}
|
||||||
|
onclick={() => handleSwitch('listenbrainz')}
|
||||||
|
>
|
||||||
|
ListenBrainz
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm join-item {currentSource === 'lastfm' ? 'btn-lastfm' : 'btn-ghost'}"
|
||||||
|
disabled={switching}
|
||||||
|
onclick={() => handleSwitch('lastfm')}
|
||||||
|
>
|
||||||
|
Last.fm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -330,7 +330,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid-cards-overview lg:col-span-2">
|
<div class="grid-cards-overview lg:col-span-2">
|
||||||
{#each items.slice(0, 8) as item, idx (item.mbid)}
|
{#each items.slice(0, 8) as item, idx (idx)}
|
||||||
{@const rank = idx + 2}
|
{@const rank = idx + 2}
|
||||||
{@const itemHref = getItemHref(item)}
|
{@const itemHref = getItemHref(item)}
|
||||||
<TimeRangeCard
|
<TimeRangeCard
|
||||||
@@ -351,7 +351,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if expandedData}
|
{:else if expandedData}
|
||||||
<div class="grid-cards">
|
<div class="grid-cards">
|
||||||
{#each expandedData.items as item, idx (item.mbid)}
|
{#each expandedData.items as item, idx (idx)}
|
||||||
{@const rank = idx + 1}
|
{@const rank = idx + 1}
|
||||||
{@const itemHref = getItemHref(item)}
|
{@const itemHref = getItemHref(item)}
|
||||||
<TimeRangeCard
|
<TimeRangeCard
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
memory_entries: number;
|
memory_entries: number;
|
||||||
memory_size_bytes: number;
|
memory_size_bytes: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { preferencesStore } from '$lib/stores/preferences';
|
import { preferencesStore } from '$lib/stores/preferences';
|
||||||
import { integrationStore } from '$lib/stores/integration';
|
import { integrationStore } from '$lib/stores/integration';
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const API = {
|
|||||||
suggest: (query: string, limit = 5) =>
|
suggest: (query: string, limit = 5) =>
|
||||||
`/api/v1/search/suggest?q=${encodeURIComponent(query.trim())}&limit=${limit}`
|
`/api/v1/search/suggest?q=${encodeURIComponent(query.trim())}&limit=${limit}`
|
||||||
},
|
},
|
||||||
home: () => '/api/v1/home',
|
home: (source: string) => `/api/v1/home?source=${encodeURIComponent(source)}`,
|
||||||
homeIntegrationStatus: () => '/api/v1/home/integration-status',
|
homeIntegrationStatus: () => '/api/v1/home/integration-status',
|
||||||
discover: () => '/api/v1/discover',
|
discover: () => '/api/v1/discover',
|
||||||
discoverRefresh: () => '/api/v1/discover/refresh',
|
discoverRefresh: () => '/api/v1/discover/refresh',
|
||||||
@@ -198,6 +198,7 @@ export const API = {
|
|||||||
},
|
},
|
||||||
queue: () => '/api/v1/queue',
|
queue: () => '/api/v1/queue',
|
||||||
settings: () => '/api/v1/settings',
|
settings: () => '/api/v1/settings',
|
||||||
|
settingsPrimarySource: () => '/api/v1/settings/primary-source',
|
||||||
settingsNavidrome: () => '/api/v1/settings/navidrome',
|
settingsNavidrome: () => '/api/v1/settings/navidrome',
|
||||||
settingsNavidromeVerify: () => '/api/v1/settings/navidrome/verify',
|
settingsNavidromeVerify: () => '/api/v1/settings/navidrome/verify',
|
||||||
settingsLocalFiles: () => '/api/v1/settings/local-files',
|
settingsLocalFiles: () => '/api/v1/settings/local-files',
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { api } from '$lib/api/client';
|
||||||
|
import { API, CACHE_TTL } from '$lib/constants';
|
||||||
|
import type { MusicSource } from '$lib/stores/musicSource';
|
||||||
|
import type { HomeResponse } from '$lib/types';
|
||||||
|
import type { Getter } from '$lib/utils/typeHelpers';
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
|
||||||
|
const keyFactory = {
|
||||||
|
home: (source: MusicSource) => ['home', source] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHomeQuery = (getSource: Getter<MusicSource>) =>
|
||||||
|
createQuery(() => ({
|
||||||
|
staleTime: CACHE_TTL.HOME,
|
||||||
|
queryKey: keyFactory.home(getSource()),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
api.global.get<HomeResponse>(API.home(getSource()), {
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
}));
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type {
|
||||||
|
AsyncStorage,
|
||||||
|
PersistedClient,
|
||||||
|
PersistedQuery,
|
||||||
|
Persister
|
||||||
|
} from '@tanstack/svelte-query-persist-client';
|
||||||
|
import { del, entries, get, set } from 'idb-keyval';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Indexed DB persister
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
|
||||||
|
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#building-a-persister
|
||||||
|
*/
|
||||||
|
export function createIDBPersister(idbValidKey: string = 'tanstackQuery') {
|
||||||
|
return {
|
||||||
|
persistClient: async (client: PersistedClient) => {
|
||||||
|
await set(idbValidKey, client);
|
||||||
|
},
|
||||||
|
restoreClient: async () => {
|
||||||
|
return await get<PersistedClient>(idbValidKey);
|
||||||
|
},
|
||||||
|
removeClient: async () => {
|
||||||
|
await del(idbValidKey);
|
||||||
|
}
|
||||||
|
} satisfies Persister;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIDBStorage(): AsyncStorage<PersistedQuery> {
|
||||||
|
return {
|
||||||
|
getItem: async (key: string) => {
|
||||||
|
const val = await get<PersistedQuery>(key);
|
||||||
|
return val;
|
||||||
|
},
|
||||||
|
setItem: async (key: string, value: PersistedQuery) => {
|
||||||
|
// In some cases, a svelte state proxy value appears in the query state, which cannot be stored in IndexedDB.
|
||||||
|
// To work around this, we can snapshot the value before storing it.
|
||||||
|
console.debug('Setting item in IndexedDB', key, value);
|
||||||
|
try {
|
||||||
|
await set(key, $state.snapshot(value));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set item in IndexedDB', key, value, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeItem: async (key: string) => {
|
||||||
|
await del(key);
|
||||||
|
},
|
||||||
|
entries: async () => {
|
||||||
|
return await entries();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import {
|
||||||
|
type InferDataFromTag,
|
||||||
|
QueryClient,
|
||||||
|
type QueryFilters,
|
||||||
|
type QueryKey,
|
||||||
|
type SetDataOptions,
|
||||||
|
type Updater
|
||||||
|
} from '@tanstack/svelte-query';
|
||||||
|
import { experimental_createQueryPersister } from '@tanstack/svelte-query-persist-client';
|
||||||
|
import { createIDBStorage } from './IndexedDbPersister.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum age for queries to be persisted.
|
||||||
|
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
|
||||||
|
*/
|
||||||
|
export const QUERY_MAX_AGE = 1000 * 60 * 60 * 24 * 7; // 7 days
|
||||||
|
|
||||||
|
export const queryPersister = experimental_createQueryPersister({
|
||||||
|
storage: createIDBStorage(),
|
||||||
|
maxAge: QUERY_MAX_AGE,
|
||||||
|
// No need to serialize/deserialize since we're using IndexedDB which can store complex objects.
|
||||||
|
serialize: (persistedQuery) => persistedQuery,
|
||||||
|
deserialize: (cached) => cached
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setQueriesDataWithPersister = async <TQueryFnData>(
|
||||||
|
filters: QueryFilters,
|
||||||
|
updater: Updater<NoInfer<TQueryFnData> | undefined, NoInfer<TQueryFnData> | undefined>
|
||||||
|
) => {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
const setQueriesDataReturn = queryClient.setQueriesData<TQueryFnData>(filters, updater);
|
||||||
|
for (const modifiedQuery of setQueriesDataReturn) {
|
||||||
|
await queryPersister.persistQueryByKey(modifiedQuery[0], queryClient);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const setQueryDataWithPersister = async <
|
||||||
|
TQueryFnData = unknown,
|
||||||
|
TTaggedQueryKey extends QueryKey = QueryKey,
|
||||||
|
TInferredQueryFnData = InferDataFromTag<TQueryFnData, TTaggedQueryKey>
|
||||||
|
>(
|
||||||
|
queryKey: TTaggedQueryKey,
|
||||||
|
updater: Updater<
|
||||||
|
NoInfer<TInferredQueryFnData> | undefined,
|
||||||
|
NoInfer<TInferredQueryFnData> | undefined
|
||||||
|
>,
|
||||||
|
options?: SetDataOptions
|
||||||
|
) => {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
await queryClient.setQueryData<TQueryFnData, TTaggedQueryKey, TInferredQueryFnData>(
|
||||||
|
queryKey,
|
||||||
|
updater,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
await queryPersister.persistQueryByKey(queryKey, queryClient);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global query client, used to manage all queries in the application.
|
||||||
|
*/
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
enabled: browser,
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
staleTime: 1000 * 60 * 1, // 1 minute,
|
||||||
|
gcTime: 1000 * 30, // 30 seconds
|
||||||
|
persister: queryPersister.persisterFn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type Snippet } from 'svelte';
|
||||||
|
import { queryClient } from './QueryClient';
|
||||||
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
|
import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{@render children()}
|
||||||
|
{#if dev}
|
||||||
|
<SvelteQueryDevtools client={queryClient} />
|
||||||
|
{/if}
|
||||||
|
</QueryClientProvider>
|
||||||
@@ -7,14 +7,14 @@ export type MusicSource = 'listenbrainz' | 'lastfm';
|
|||||||
export type MusicSourcePage = keyof typeof PAGE_SOURCE_KEYS;
|
export type MusicSourcePage = keyof typeof PAGE_SOURCE_KEYS;
|
||||||
|
|
||||||
const CACHED_SOURCE_KEY = 'musicseerr_primary_source';
|
const CACHED_SOURCE_KEY = 'musicseerr_primary_source';
|
||||||
const DEFAULT_SOURCE: MusicSource = 'listenbrainz';
|
export const DEFAULT_SOURCE: MusicSource = 'listenbrainz';
|
||||||
|
|
||||||
interface MusicSourceState {
|
interface MusicSourceState {
|
||||||
source: MusicSource;
|
source: MusicSource;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMusicSource(value: unknown): value is MusicSource {
|
export function isMusicSource(value: unknown): value is MusicSource {
|
||||||
return value === 'listenbrainz' || value === 'lastfm';
|
return value === 'listenbrainz' || value === 'lastfm';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -823,7 +823,6 @@ describe('beforeunload beacon', () => {
|
|||||||
let addEventListenerSpy: ReturnType<typeof vi.fn>;
|
let addEventListenerSpy: ReturnType<typeof vi.fn>;
|
||||||
let removeEventListenerSpy: ReturnType<typeof vi.fn>;
|
let removeEventListenerSpy: ReturnType<typeof vi.fn>;
|
||||||
let sendBeaconMock: ReturnType<typeof vi.fn>;
|
let sendBeaconMock: ReturnType<typeof vi.fn>;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
let jellyfinApi: {
|
let jellyfinApi: {
|
||||||
startSession: ReturnType<typeof vi.fn>;
|
startSession: ReturnType<typeof vi.fn>;
|
||||||
reportProgress: ReturnType<typeof vi.fn>;
|
reportProgress: ReturnType<typeof vi.fn>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { libraryStore } from '$lib/stores/library';
|
import { libraryStore } from '$lib/stores/library';
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
|
|
||||||
export type SyncStatus = {
|
export type SyncStatus = {
|
||||||
is_syncing: boolean;
|
is_syncing: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isValidMbid } from '$lib/utils/formatting';
|
import { isValidMbid } from '$lib/utils/formatting';
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
|
|
||||||
export function isAbortError(error: unknown): boolean {
|
export function isAbortError(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -164,17 +164,3 @@ describe('raw fetch is not affected by navigation abort', () => {
|
|||||||
expect(callArgs[1]?.signal).toBeUndefined();
|
expect(callArgs[1]?.signal).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SSR safety', () => {
|
|
||||||
it('falls back to raw fetch when window is undefined', async () => {
|
|
||||||
vi.stubGlobal('window', undefined);
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue(new Response('ok'));
|
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
|
||||||
|
|
||||||
await pageFetch('/api/v1/test');
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledOnce();
|
|
||||||
const callArgs = mockFetch.mock.calls[0];
|
|
||||||
expect(callArgs[1]?.signal).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export function abortAllPageRequests(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pageFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
export async function pageFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||||
if (typeof window === 'undefined') return fetch(input, init);
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Can never happen, we are running in SPA mode');
|
||||||
|
}
|
||||||
const navSignal = getNavigationSignal();
|
const navSignal = getNavigationSignal();
|
||||||
const existingSignal = init?.signal;
|
const existingSignal = init?.signal;
|
||||||
const signal = existingSignal ? AbortSignal.any([navSignal, existingSignal]) : navSignal;
|
const signal = existingSignal ? AbortSignal.any([navSignal, existingSignal]) : navSignal;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type Getter<T> = () => T;
|
||||||
|
|
||||||
|
export type MaybeGetter<T> = T | Getter<T>;
|
||||||
+330
-327
@@ -51,6 +51,7 @@
|
|||||||
ListMusic
|
ListMusic
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import QueryProvider from '$lib/queries/QueryProvider.svelte';
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
@@ -234,344 +235,346 @@
|
|||||||
const lidarrConfigured = $derived(integrations.current.lidarr || !integrations.current.loaded);
|
const lidarrConfigured = $derived(integrations.current.lidarr || !integrations.current.loaded);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div data-theme="musicseerr">
|
<QueryProvider>
|
||||||
{#if showNavigationProgress}
|
<div data-theme="musicseerr">
|
||||||
<div class="fixed top-0 left-0 right-0 z-120 pointer-events-none">
|
{#if showNavigationProgress}
|
||||||
<progress class="progress progress-primary w-full h-1"></progress>
|
<div class="fixed top-0 left-0 right-0 z-120 pointer-events-none">
|
||||||
</div>
|
<progress class="progress progress-primary w-full h-1"></progress>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<DegradedBanner />
|
|
||||||
|
|
||||||
<div class="drawer drawer-open">
|
|
||||||
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
|
||||||
|
|
||||||
<div class="drawer-content flex flex-col">
|
|
||||||
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-50">
|
|
||||||
<div class="navbar-start w-auto">
|
|
||||||
<a href="/" class="btn btn-ghost" aria-label="Home">
|
|
||||||
<img src="/logo_wide.png" alt="Musicseerr" class="h-8" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-center grow px-4 justify-center">
|
|
||||||
<div class="w-full max-w-2xl">
|
|
||||||
<SearchSuggestions
|
|
||||||
bind:query
|
|
||||||
onSearch={handleSearch}
|
|
||||||
onSelect={handleSuggestionSelect}
|
|
||||||
id="navbar-suggest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-end w-auto pr-2">
|
|
||||||
<a href="/profile" class="btn btn-ghost btn-circle btn-md" aria-label="Profile">
|
|
||||||
<UserRound class="h-6 w-6" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1" class:pb-24={playerStore.isPlayerVisible}>
|
<DegradedBanner />
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="drawer-side is-drawer-close:overflow-visible">
|
<div class="drawer drawer-open">
|
||||||
<label for="main-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
||||||
<div
|
|
||||||
class="is-drawer-close:w-16 is-drawer-open:w-64 bg-base-200 flex flex-col items-start min-h-full"
|
|
||||||
>
|
|
||||||
<ul class="menu w-full grow p-2 [&_li>*]:py-3">
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
onclick={() =>
|
|
||||||
(document.getElementById('search_modal') as HTMLDialogElement)?.showModal()}
|
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
data-tip="Search"
|
|
||||||
>
|
|
||||||
<Search class="h-6 w-6" />
|
|
||||||
<span class="is-drawer-close:hidden">Search</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<div class="divider my-0"></div>
|
<div class="drawer-content flex flex-col">
|
||||||
|
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-50">
|
||||||
<li>
|
<div class="navbar-start w-auto">
|
||||||
<a
|
<a href="/" class="btn btn-ghost" aria-label="Home">
|
||||||
href="/"
|
<img src="/logo_wide.png" alt="Musicseerr" class="h-8" />
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
data-tip="Home"
|
|
||||||
>
|
|
||||||
<House class="h-6 w-6" />
|
|
||||||
<span class="is-drawer-close:hidden">Home</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/discover"
|
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
data-tip="Discover"
|
|
||||||
>
|
|
||||||
<Compass class="h-6 w-6" />
|
|
||||||
<span class="is-drawer-close:hidden">Discover</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{#if lidarrConfigured}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/library"
|
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
data-tip="Library"
|
|
||||||
>
|
|
||||||
<div class="relative">
|
|
||||||
<Menu class="h-6 w-6" />
|
|
||||||
{#if syncStatus.isActive}
|
|
||||||
<span
|
|
||||||
class="absolute -top-1 -right-1 badge badge-primary badge-xs w-2.5 h-2.5 p-0 animate-pulse"
|
|
||||||
aria-label="Library sync in progress"
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class="is-drawer-close:hidden">Library</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/playlists"
|
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
class:menu-active={isNavActive('/playlists')}
|
|
||||||
aria-current={isNavActive('/playlists') ? 'page' : undefined}
|
|
||||||
data-tip="Playlists"
|
|
||||||
>
|
|
||||||
<ListMusic class="h-6 w-6" />
|
|
||||||
<span class="is-drawer-close:hidden">Playlists</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if integrations.current.loaded}
|
|
||||||
<div class="divider my-0"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if integrations.current.youtube}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/library/youtube"
|
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
data-tip="YouTube"
|
|
||||||
>
|
|
||||||
<YouTubeIcon class="h-6 w-6 text-error" />
|
|
||||||
<span class="is-drawer-close:hidden">YouTube</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{:else if integrations.current.loaded}
|
|
||||||
<SidebarServiceHint label="YouTube" settingsTab="youtube">
|
|
||||||
{#snippet icon()}<YouTubeIcon class="h-6 w-6 text-error" />{/snippet}
|
|
||||||
</SidebarServiceHint>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if integrations.current.jellyfin}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/library/jellyfin"
|
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
data-tip="Jellyfin"
|
|
||||||
>
|
|
||||||
<JellyfinIcon class="h-6 w-6 text-info" />
|
|
||||||
<span class="is-drawer-close:hidden">Jellyfin</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{:else if integrations.current.loaded}
|
|
||||||
<SidebarServiceHint label="Jellyfin" settingsTab="jellyfin">
|
|
||||||
{#snippet icon()}<JellyfinIcon class="h-6 w-6 text-info" />{/snippet}
|
|
||||||
</SidebarServiceHint>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if integrations.current.navidrome}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/library/navidrome"
|
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
data-tip="Navidrome"
|
|
||||||
>
|
|
||||||
<NavidromeIcon class="h-6 w-6 text-primary" />
|
|
||||||
<span class="is-drawer-close:hidden">Navidrome</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{:else if integrations.current.loaded}
|
|
||||||
<SidebarServiceHint label="Navidrome" settingsTab="navidrome">
|
|
||||||
{#snippet icon()}<NavidromeIcon class="h-6 w-6 text-primary" />{/snippet}
|
|
||||||
</SidebarServiceHint>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if integrations.current.localfiles}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/library/local"
|
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
data-tip="Local Files"
|
|
||||||
>
|
|
||||||
<Headphones class="h-6 w-6 text-accent" />
|
|
||||||
<span class="is-drawer-close:hidden">Local Files</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{:else if integrations.current.loaded}
|
|
||||||
<SidebarServiceHint label="Local Files" settingsTab="local-files">
|
|
||||||
{#snippet icon()}<Headphones class="h-6 w-6 text-accent" />{/snippet}
|
|
||||||
</SidebarServiceHint>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if lidarrConfigured}
|
|
||||||
<div class="divider my-0"></div>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/requests"
|
|
||||||
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
||||||
data-tip="Requests"
|
|
||||||
>
|
|
||||||
<div class="relative">
|
|
||||||
<Download class="h-6 w-6" />
|
|
||||||
{#if requestCountStore.count > 0}
|
|
||||||
<span
|
|
||||||
class="absolute -top-2 -right-2 badge badge-info badge-xs w-4 h-4 p-0 text-[10px] font-bold"
|
|
||||||
>{requestCountStore.count}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class="is-drawer-close:hidden">Requests</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
<div class="w-full p-2 flex flex-col gap-1" class:pb-24={playerStore.isPlayerVisible}>
|
|
||||||
<div class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
|
|
||||||
<a href="/settings" class="btn btn-ghost btn-circle" aria-label="Settings">
|
|
||||||
<Settings class="h-6 w-6" />
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Open">
|
<div class="navbar-center grow px-4 justify-center">
|
||||||
<label
|
<div class="w-full max-w-2xl">
|
||||||
for="main-drawer"
|
<SearchSuggestions
|
||||||
class="btn btn-ghost btn-circle drawer-button is-drawer-open:rotate-y-180"
|
bind:query
|
||||||
>
|
onSearch={handleSearch}
|
||||||
<PanelLeft class="h-6 w-6" />
|
onSelect={handleSuggestionSelect}
|
||||||
</label>
|
id="navbar-suggest"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dialog id="search_modal" class="modal">
|
|
||||||
<div class="modal-box overflow-visible">
|
|
||||||
<form method="dialog">
|
|
||||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" aria-label="Close"
|
|
||||||
><X class="h-4 w-4" /></button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
<h3 class="font-bold text-lg mb-4">Search</h3>
|
|
||||||
<SearchSuggestions
|
|
||||||
bind:query={modalQuery}
|
|
||||||
onSearch={handleModalSearch}
|
|
||||||
onSelect={handleModalSuggestionSelect}
|
|
||||||
placeholder="Search albums or artists..."
|
|
||||||
autofocus={true}
|
|
||||||
id="modal-suggest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop">
|
|
||||||
<button aria-label="Close modal">close</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
{#if $errorModal.show}
|
|
||||||
<dialog class="modal modal-open">
|
|
||||||
<div class="modal-box bg-base-200 border border-base-300 shadow-xl max-w-md">
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3 opacity-60 hover:opacity-100"
|
|
||||||
onclick={() => errorModal.hide()}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center text-center pt-2 pb-1">
|
|
||||||
<div class="bg-error/10 rounded-full p-3 mb-4">
|
|
||||||
<TriangleAlert class="h-8 w-8 text-error" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="text-lg font-bold text-base-content mb-2">
|
|
||||||
{$errorModal.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p class="text-sm text-base-content/70 leading-relaxed">
|
|
||||||
{$errorModal.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $errorModal.details}
|
|
||||||
<div class="mt-4 rounded-box bg-base-300/60 border border-base-300 p-4">
|
|
||||||
<div class="flex gap-3 items-start">
|
|
||||||
<Info class="h-5 w-5 text-info shrink-0 mt-0.5" />
|
|
||||||
<p class="text-sm text-base-content/80 leading-relaxed text-left">
|
|
||||||
{$errorModal.details}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="navbar-end w-auto pr-2">
|
||||||
|
<a href="/profile" class="btn btn-ghost btn-circle btn-md" aria-label="Profile">
|
||||||
|
<UserRound class="h-6 w-6" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-action justify-center mt-5">
|
<div class="flex-1" class:pb-24={playerStore.isPlayerVisible}>
|
||||||
<button class="btn btn-accent btn-sm px-6" onclick={() => errorModal.hide()}>
|
{@render children()}
|
||||||
Dismiss
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-side is-drawer-close:overflow-visible">
|
||||||
|
<label for="main-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||||
|
<div
|
||||||
|
class="is-drawer-close:w-16 is-drawer-open:w-64 bg-base-200 flex flex-col items-start min-h-full"
|
||||||
|
>
|
||||||
|
<ul class="menu w-full grow p-2 [&_li>*]:py-3">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onclick={() =>
|
||||||
|
(document.getElementById('search_modal') as HTMLDialogElement)?.showModal()}
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
data-tip="Search"
|
||||||
|
>
|
||||||
|
<Search class="h-6 w-6" />
|
||||||
|
<span class="is-drawer-close:hidden">Search</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
data-tip="Home"
|
||||||
|
>
|
||||||
|
<House class="h-6 w-6" />
|
||||||
|
<span class="is-drawer-close:hidden">Home</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/discover"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
data-tip="Discover"
|
||||||
|
>
|
||||||
|
<Compass class="h-6 w-6" />
|
||||||
|
<span class="is-drawer-close:hidden">Discover</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{#if lidarrConfigured}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/library"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
data-tip="Library"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<Menu class="h-6 w-6" />
|
||||||
|
{#if syncStatus.isActive}
|
||||||
|
<span
|
||||||
|
class="absolute -top-1 -right-1 badge badge-primary badge-xs w-2.5 h-2.5 p-0 animate-pulse"
|
||||||
|
aria-label="Library sync in progress"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="is-drawer-close:hidden">Library</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/playlists"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
class:menu-active={isNavActive('/playlists')}
|
||||||
|
aria-current={isNavActive('/playlists') ? 'page' : undefined}
|
||||||
|
data-tip="Playlists"
|
||||||
|
>
|
||||||
|
<ListMusic class="h-6 w-6" />
|
||||||
|
<span class="is-drawer-close:hidden">Playlists</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if integrations.current.loaded}
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if integrations.current.youtube}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/library/youtube"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
data-tip="YouTube"
|
||||||
|
>
|
||||||
|
<YouTubeIcon class="h-6 w-6 text-error" />
|
||||||
|
<span class="is-drawer-close:hidden">YouTube</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{:else if integrations.current.loaded}
|
||||||
|
<SidebarServiceHint label="YouTube" settingsTab="youtube">
|
||||||
|
{#snippet icon()}<YouTubeIcon class="h-6 w-6 text-error" />{/snippet}
|
||||||
|
</SidebarServiceHint>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if integrations.current.jellyfin}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/library/jellyfin"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
data-tip="Jellyfin"
|
||||||
|
>
|
||||||
|
<JellyfinIcon class="h-6 w-6 text-info" />
|
||||||
|
<span class="is-drawer-close:hidden">Jellyfin</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{:else if integrations.current.loaded}
|
||||||
|
<SidebarServiceHint label="Jellyfin" settingsTab="jellyfin">
|
||||||
|
{#snippet icon()}<JellyfinIcon class="h-6 w-6 text-info" />{/snippet}
|
||||||
|
</SidebarServiceHint>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if integrations.current.navidrome}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/library/navidrome"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
data-tip="Navidrome"
|
||||||
|
>
|
||||||
|
<NavidromeIcon class="h-6 w-6 text-primary" />
|
||||||
|
<span class="is-drawer-close:hidden">Navidrome</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{:else if integrations.current.loaded}
|
||||||
|
<SidebarServiceHint label="Navidrome" settingsTab="navidrome">
|
||||||
|
{#snippet icon()}<NavidromeIcon class="h-6 w-6 text-primary" />{/snippet}
|
||||||
|
</SidebarServiceHint>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if integrations.current.localfiles}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/library/local"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
data-tip="Local Files"
|
||||||
|
>
|
||||||
|
<Headphones class="h-6 w-6 text-accent" />
|
||||||
|
<span class="is-drawer-close:hidden">Local Files</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{:else if integrations.current.loaded}
|
||||||
|
<SidebarServiceHint label="Local Files" settingsTab="local-files">
|
||||||
|
{#snippet icon()}<Headphones class="h-6 w-6 text-accent" />{/snippet}
|
||||||
|
</SidebarServiceHint>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if lidarrConfigured}
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/requests"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||||
|
data-tip="Requests"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<Download class="h-6 w-6" />
|
||||||
|
{#if requestCountStore.count > 0}
|
||||||
|
<span
|
||||||
|
class="absolute -top-2 -right-2 badge badge-info badge-xs w-4 h-4 p-0 text-[10px] font-bold"
|
||||||
|
>{requestCountStore.count}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="is-drawer-close:hidden">Requests</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
<div class="w-full p-2 flex flex-col gap-1" class:pb-24={playerStore.isPlayerVisible}>
|
||||||
|
<div class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
|
||||||
|
<a href="/settings" class="btn btn-ghost btn-circle" aria-label="Settings">
|
||||||
|
<Settings class="h-6 w-6" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Open">
|
||||||
|
<label
|
||||||
|
for="main-drawer"
|
||||||
|
class="btn btn-ghost btn-circle drawer-button is-drawer-open:rotate-y-180"
|
||||||
|
>
|
||||||
|
<PanelLeft class="h-6 w-6" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="search_modal" class="modal">
|
||||||
|
<div class="modal-box overflow-visible">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" aria-label="Close"
|
||||||
|
><X class="h-4 w-4" /></button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<h3 class="font-bold text-lg mb-4">Search</h3>
|
||||||
|
<SearchSuggestions
|
||||||
|
bind:query={modalQuery}
|
||||||
|
onSearch={handleModalSearch}
|
||||||
|
onSelect={handleModalSuggestionSelect}
|
||||||
|
placeholder="Search albums or artists..."
|
||||||
|
autofocus={true}
|
||||||
|
id="modal-suggest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button aria-label="Close modal">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
{#if $errorModal.show}
|
||||||
|
<dialog class="modal modal-open">
|
||||||
|
<div class="modal-box bg-base-200 border border-base-300 shadow-xl max-w-md">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3 opacity-60 hover:opacity-100"
|
||||||
|
onclick={() => errorModal.hide()}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center text-center pt-2 pb-1">
|
||||||
|
<div class="bg-error/10 rounded-full p-3 mb-4">
|
||||||
|
<TriangleAlert class="h-8 w-8 text-error" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-bold text-base-content mb-2">
|
||||||
|
{$errorModal.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-sm text-base-content/70 leading-relaxed">
|
||||||
|
{$errorModal.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $errorModal.details}
|
||||||
|
<div class="mt-4 rounded-box bg-base-300/60 border border-base-300 p-4">
|
||||||
|
<div class="flex gap-3 items-start">
|
||||||
|
<Info class="h-5 w-5 text-info shrink-0 mt-0.5" />
|
||||||
|
<p class="text-sm text-base-content/80 leading-relaxed text-left">
|
||||||
|
{$errorModal.details}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="modal-action justify-center mt-5">
|
||||||
|
<button class="btn btn-accent btn-sm px-6" onclick={() => errorModal.hide()}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<form method="dialog" class="modal-backdrop" onclick={() => errorModal.hide()}>
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if playbackToast.visible}
|
||||||
|
<div
|
||||||
|
class="fixed z-50 left-1/2 -translate-x-1/2 transition-all duration-300"
|
||||||
|
style="bottom: {playerStore.isPlayerVisible ? '100px' : '16px'}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="alert {playbackToast.type === 'error'
|
||||||
|
? 'alert-error'
|
||||||
|
: playbackToast.type === 'warning'
|
||||||
|
? 'alert-warning'
|
||||||
|
: 'alert-info'} shadow-lg px-4 py-2 min-w-64 max-w-md"
|
||||||
|
>
|
||||||
|
{#if playbackToast.type === 'error'}
|
||||||
|
<X class="h-5 w-5 shrink-0" />
|
||||||
|
{:else if playbackToast.type === 'warning'}
|
||||||
|
<TriangleAlert class="h-5 w-5 shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<Info class="h-5 w-5 shrink-0" />
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm">{playbackToast.message}</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs btn-circle"
|
||||||
|
onclick={() => playbackToast.dismiss()}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X class="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
{/if}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<form method="dialog" class="modal-backdrop" onclick={() => errorModal.hide()}>
|
|
||||||
<button>close</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if playbackToast.visible}
|
{#if browser}
|
||||||
<div
|
<audio bind:this={audioElement}></audio>
|
||||||
class="fixed z-50 left-1/2 -translate-x-1/2 transition-all duration-300"
|
{/if}
|
||||||
style="bottom: {playerStore.isPlayerVisible ? '100px' : '16px'}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="alert {playbackToast.type === 'error'
|
|
||||||
? 'alert-error'
|
|
||||||
: playbackToast.type === 'warning'
|
|
||||||
? 'alert-warning'
|
|
||||||
: 'alert-info'} shadow-lg px-4 py-2 min-w-64 max-w-md"
|
|
||||||
>
|
|
||||||
{#if playbackToast.type === 'error'}
|
|
||||||
<X class="h-5 w-5 shrink-0" />
|
|
||||||
{:else if playbackToast.type === 'warning'}
|
|
||||||
<TriangleAlert class="h-5 w-5 shrink-0" />
|
|
||||||
{:else}
|
|
||||||
<Info class="h-5 w-5 shrink-0" />
|
|
||||||
{/if}
|
|
||||||
<span class="text-sm">{playbackToast.message}</span>
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-xs btn-circle"
|
|
||||||
onclick={() => playbackToast.dismiss()}
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
<X class="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if browser}
|
<Player />
|
||||||
<audio bind:this={audioElement}></audio>
|
<CacheSyncIndicator />
|
||||||
{/if}
|
<AddToPlaylistModal bind:this={playlistModalRef} />
|
||||||
|
</div>
|
||||||
<Player />
|
</QueryProvider>
|
||||||
<CacheSyncIndicator />
|
|
||||||
<AddToPlaylistModal bind:this={playlistModalRef} />
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { api } from '$lib/api/client';
|
||||||
|
import { API } from '$lib/constants';
|
||||||
|
import { DEFAULT_SOURCE, isMusicSource } from '$lib/stores/musicSource';
|
||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
|
// Disable SSR, because we currently use only CSR
|
||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const load: LayoutLoad = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.global.get<{ source: unknown }>(API.settingsPrimarySource());
|
||||||
|
const primarySource = isMusicSource(data.source) ? data.source : DEFAULT_SOURCE;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primarySource
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching primary music source:', error);
|
||||||
|
return {
|
||||||
|
primarySource: DEFAULT_SOURCE
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,174 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Shield, Music, CircleAlert, TrendingUp, Sparkles, Library } from 'lucide-svelte';
|
import { Shield, Music, CircleAlert, TrendingUp, Sparkles, Library } from 'lucide-svelte';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import { beforeNavigate } from '$app/navigation';
|
|
||||||
import HomeSection from '$lib/components/HomeSection.svelte';
|
import HomeSection from '$lib/components/HomeSection.svelte';
|
||||||
import WeeklyExploration from '$lib/components/WeeklyExploration.svelte';
|
import WeeklyExploration from '$lib/components/WeeklyExploration.svelte';
|
||||||
import ServicePromptCard from '$lib/components/ServicePromptCard.svelte';
|
import ServicePromptCard from '$lib/components/ServicePromptCard.svelte';
|
||||||
import GenreGrid from '$lib/components/GenreGrid.svelte';
|
import GenreGrid from '$lib/components/GenreGrid.svelte';
|
||||||
import SourceSwitcher from '$lib/components/SourceSwitcher.svelte';
|
|
||||||
import SectionDivider from '$lib/components/SectionDivider.svelte';
|
import SectionDivider from '$lib/components/SectionDivider.svelte';
|
||||||
import type {
|
import type {
|
||||||
HomeResponse,
|
|
||||||
HomeSection as HomeSectionType,
|
HomeSection as HomeSectionType,
|
||||||
WeeklyExplorationSection as WeeklyExplorationSectionType
|
WeeklyExplorationSection as WeeklyExplorationSectionType
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import { integrationStore } from '$lib/stores/integration';
|
import { type MusicSource } from '$lib/stores/musicSource';
|
||||||
import { musicSourceStore, type MusicSource } from '$lib/stores/musicSource';
|
|
||||||
import CarouselSkeleton from '$lib/components/CarouselSkeleton.svelte';
|
import CarouselSkeleton from '$lib/components/CarouselSkeleton.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import {
|
import { getGreeting } from '$lib/utils/homeCache';
|
||||||
getHomeCachedData,
|
|
||||||
setHomeCachedData,
|
|
||||||
isHomeCacheStale,
|
|
||||||
getGreeting
|
|
||||||
} from '$lib/utils/homeCache';
|
|
||||||
import { isAbortError } from '$lib/utils/errorHandling';
|
|
||||||
import { api } from '$lib/api/client';
|
|
||||||
import { removeQueueCachedData } from '$lib/utils/discoverQueueCache';
|
import { removeQueueCachedData } from '$lib/utils/discoverQueueCache';
|
||||||
import { isDismissed } from '$lib/utils/dismissedPrompts';
|
import { isDismissed } from '$lib/utils/dismissedPrompts';
|
||||||
|
import { getHomeQuery } from '$lib/queries/HomeQuery.svelte';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { PersistedState } from 'runed';
|
||||||
|
import { PAGE_SOURCE_KEYS } from '$lib/constants';
|
||||||
|
import SimpleSourceSwitcher from '$lib/components/SimpleSourceSwitcher.svelte';
|
||||||
|
|
||||||
let homeData = $state<HomeResponse | null>(null);
|
const { data }: PageProps = $props();
|
||||||
let loading = $state(true);
|
|
||||||
let refreshing = $state(false);
|
|
||||||
let isUpdating = $state(false);
|
|
||||||
let error = $state('');
|
|
||||||
let lastUpdated = $state<Date | null>(null);
|
|
||||||
let abortController: AbortController | null = null;
|
|
||||||
let activeSource: MusicSource = 'listenbrainz';
|
|
||||||
|
|
||||||
function resolveHomeSource(source?: MusicSource): MusicSource {
|
// svelte-ignore state_referenced_locally
|
||||||
return source ?? activeSource;
|
let activeSource = new PersistedState<MusicSource>(PAGE_SOURCE_KEYS['home'], data.primarySource);
|
||||||
}
|
|
||||||
|
|
||||||
async function loadHomeData(forceRefresh = false, sourceOverride?: MusicSource) {
|
const homeQuery = getHomeQuery(() => activeSource.current);
|
||||||
const source = resolveHomeSource(sourceOverride);
|
const homeData = $derived(homeQuery.data);
|
||||||
const cached = getHomeCachedData(source);
|
const loading = $derived(homeQuery.isLoading);
|
||||||
if (cached && !forceRefresh) {
|
const isUpdating = $derived(homeQuery.isRefetching);
|
||||||
homeData = cached.data;
|
const lastUpdated = $derived(homeQuery.dataUpdatedAt ? new Date(homeQuery.dataUpdatedAt) : null);
|
||||||
lastUpdated = new Date(cached.timestamp);
|
|
||||||
loading = false;
|
|
||||||
if (isHomeCacheStale(cached.timestamp)) {
|
|
||||||
refreshInBackground(source);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (abortController) {
|
|
||||||
abortController.abort();
|
|
||||||
}
|
|
||||||
abortController = new AbortController();
|
|
||||||
|
|
||||||
if (!homeData) {
|
|
||||||
loading = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
error = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.get<HomeResponse>(
|
|
||||||
`/api/v1/home?source=${encodeURIComponent(source)}`,
|
|
||||||
{
|
|
||||||
signal: abortController.signal
|
|
||||||
}
|
|
||||||
);
|
|
||||||
homeData = data;
|
|
||||||
lastUpdated = new Date();
|
|
||||||
setHomeCachedData(data, source);
|
|
||||||
if (data.integration_status) {
|
|
||||||
integrationStore.setStatus(data.integration_status);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (isAbortError(e)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!homeData) {
|
|
||||||
error = "Couldn't load the home page";
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshInBackground(sourceOverride?: MusicSource) {
|
|
||||||
if (refreshing) return;
|
|
||||||
|
|
||||||
if (abortController) {
|
|
||||||
abortController.abort();
|
|
||||||
}
|
|
||||||
abortController = new AbortController();
|
|
||||||
refreshing = true;
|
|
||||||
isUpdating = true;
|
|
||||||
const source = resolveHomeSource(sourceOverride);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.get<HomeResponse>(
|
|
||||||
`/api/v1/home?source=${encodeURIComponent(source)}`,
|
|
||||||
{
|
|
||||||
signal: abortController.signal
|
|
||||||
}
|
|
||||||
);
|
|
||||||
homeData = data;
|
|
||||||
lastUpdated = new Date();
|
|
||||||
setHomeCachedData(data, source);
|
|
||||||
if (data.integration_status) {
|
|
||||||
integrationStore.setStatus(data.integration_status);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (isAbortError(e)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
refreshing = false;
|
|
||||||
isUpdating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRefresh() {
|
|
||||||
refreshing = true;
|
|
||||||
isUpdating = true;
|
|
||||||
const minDelay = new Promise((r) => setTimeout(r, 500));
|
|
||||||
try {
|
|
||||||
await loadHomeData(true, activeSource);
|
|
||||||
} finally {
|
|
||||||
await minDelay;
|
|
||||||
refreshing = false;
|
|
||||||
isUpdating = false;
|
|
||||||
lastUpdated = new Date();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
if (abortController) {
|
|
||||||
abortController.abort();
|
|
||||||
abortController = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
activeSource = musicSourceStore.getPageSource('home');
|
|
||||||
loadHomeData(false, activeSource);
|
|
||||||
|
|
||||||
musicSourceStore.load().then(() => {
|
|
||||||
const actualSource = musicSourceStore.getPageSource('home');
|
|
||||||
if (actualSource !== activeSource) {
|
|
||||||
const dataBefore = homeData;
|
|
||||||
loadHomeData(true, actualSource).then(() => {
|
|
||||||
if (homeData !== dataBefore) {
|
|
||||||
activeSource = actualSource;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(cleanup);
|
|
||||||
beforeNavigate(cleanup);
|
|
||||||
|
|
||||||
function handleSourceChange(source: MusicSource) {
|
function handleSourceChange(source: MusicSource) {
|
||||||
activeSource = source;
|
activeSource.current = source;
|
||||||
removeQueueCachedData();
|
removeQueueCachedData();
|
||||||
loadHomeData(true, source);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreGenreBlock =
|
type PreGenreBlock =
|
||||||
@@ -195,7 +61,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
activeSource === 'listenbrainz' &&
|
activeSource.current === 'listenbrainz' &&
|
||||||
homeData.weekly_exploration &&
|
homeData.weekly_exploration &&
|
||||||
homeData.weekly_exploration.tracks.length > 0
|
homeData.weekly_exploration.tracks.length > 0
|
||||||
) {
|
) {
|
||||||
@@ -291,10 +157,10 @@
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
subtitle="Discover music, explore your library, and find new favorites."
|
subtitle="Discover music, explore your library, and find new favorites."
|
||||||
{loading}
|
{loading}
|
||||||
{refreshing}
|
refreshing={isUpdating}
|
||||||
{isUpdating}
|
{isUpdating}
|
||||||
{lastUpdated}
|
{lastUpdated}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={() => homeQuery.refetch()}
|
||||||
>
|
>
|
||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
<Music class="inline h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 mr-2 align-text-bottom" />
|
<Music class="inline h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 mr-2 align-text-bottom" />
|
||||||
@@ -303,14 +169,17 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="flex justify-end px-4 -mt-4 mb-4 sm:px-6 lg:px-8">
|
<div class="flex justify-end px-4 -mt-4 mb-4 sm:px-6 lg:px-8">
|
||||||
<SourceSwitcher pageKey="home" onSourceChange={handleSourceChange} />
|
<SimpleSourceSwitcher
|
||||||
|
currentSource={activeSource.current}
|
||||||
|
onSourceChange={handleSourceChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error && !homeData}
|
{#if homeQuery.error && !homeData}
|
||||||
<div class="mt-16 flex flex-col items-center justify-center px-4">
|
<div class="mt-16 flex flex-col items-center justify-center px-4">
|
||||||
<CircleAlert class="mb-4 h-10 w-10 text-base-content/50" />
|
<CircleAlert class="mb-4 h-10 w-10 text-base-content/50" />
|
||||||
<p class="text-base-content/70">{error}</p>
|
<p class="text-base-content/70">{homeQuery.error.message ?? 'Failed to load Home data'}</p>
|
||||||
<button class="btn btn-primary mt-4" onclick={() => loadHomeData(true)}>Try Again</button>
|
<button class="btn btn-primary mt-4" onclick={() => homeQuery.refetch()}>Try Again</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-10 px-4 sm:space-y-12 sm:px-6 lg:px-8">
|
<div class="space-y-10 px-4 sm:space-y-12 sm:px-6 lg:px-8">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AlbumBasicInfo, AlbumTracksInfo } from '$lib/types';
|
import type { AlbumBasicInfo, AlbumTracksInfo } from '$lib/types';
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
import { colors } from '$lib/colors';
|
import { colors } from '$lib/colors';
|
||||||
import AlbumImage from '$lib/components/AlbumImage.svelte';
|
import AlbumImage from '$lib/components/AlbumImage.svelte';
|
||||||
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { beforeNavigate } from '$app/navigation';
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import { toastStore } from '$lib/stores/toast';
|
import { toastStore } from '$lib/stores/toast';
|
||||||
import { getCacheTTL } from '$lib/stores/cacheTtl';
|
import { getCacheTTL } from '$lib/stores/cacheTtl';
|
||||||
import { extractDominantColor, DEFAULT_GRADIENT } from '$lib/utils/colors';
|
import { extractDominantColor, DEFAULT_GRADIENT } from '$lib/utils/colors';
|
||||||
import { getApiUrl } from '$lib/utils/api';
|
import { getApiUrl } from '$lib/api/api-utils';
|
||||||
import { Music } from 'lucide-svelte';
|
import { Music } from 'lucide-svelte';
|
||||||
import BackButton from '$lib/components/BackButton.svelte';
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
||||||
|
|||||||
Reference in New Issue
Block a user