Compare commits
10
Commits
master
..
b011ccdd5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b011ccdd5b | ||
|
|
1df7f6ccf6 | ||
|
|
2915737f03 | ||
|
|
5b2a0e4d46 | ||
|
|
37fc229bf6 | ||
|
|
bc43b4b6eb | ||
|
|
dda8c30d45 | ||
|
|
906f38140a | ||
|
|
3e546d4a1e | ||
|
|
b2f450dcf7 |
@@ -21,6 +21,8 @@ namespace Musify.Application.PlayLists
|
||||
.Select(p => new
|
||||
{
|
||||
PlayList = p,
|
||||
TrackCount = p.PlayListTracks.Count,
|
||||
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||
CoverTrackIds = p.PlayListTracks
|
||||
.OrderBy(plt => plt.Position)
|
||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||
@@ -36,7 +38,7 @@ namespace Musify.Application.PlayLists
|
||||
&& entry.PlayList.UserId != request.RequestingUserId)
|
||||
return Error.NotFound();
|
||||
|
||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
|
||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace Musify.Application.PlayLists
|
||||
.Select(p => new
|
||||
{
|
||||
PlayList = p,
|
||||
TrackCount = p.PlayListTracks.Count,
|
||||
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||
CoverTrackIds = p.PlayListTracks
|
||||
.OrderBy(plt => plt.Position)
|
||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||
@@ -34,7 +36,7 @@ namespace Musify.Application.PlayLists
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
|
||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||
|
||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
||||
|
||||
@@ -46,6 +46,8 @@ namespace Musify.Application.PlayLists
|
||||
.Select(p => new
|
||||
{
|
||||
PlayList = p,
|
||||
TrackCount = p.PlayListTracks.Count,
|
||||
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||
CoverTrackIds = p.PlayListTracks
|
||||
.OrderBy(plt => plt.Position)
|
||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||
@@ -55,7 +57,7 @@ namespace Musify.Application.PlayLists
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
|
||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||
|
||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
@@ -10,7 +12,10 @@ namespace Musify.Application.PlayLists.Responses
|
||||
string? SmallImageKeyName,
|
||||
string? MediumImageKeyName,
|
||||
string? LargeImageKeyName,
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||
PlaylistVisibility Visibility,
|
||||
int TrackCount,
|
||||
double DurationSeconds,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<Guid> CoverTrackIds)
|
||||
@@ -19,6 +24,8 @@ namespace Musify.Application.PlayLists.Responses
|
||||
|
||||
public static PlayListApplicationResponse FromEntity(
|
||||
PlayList playList,
|
||||
int trackCount = 0,
|
||||
double durationSeconds = 0,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
{
|
||||
return new PlayListApplicationResponse(
|
||||
@@ -28,7 +35,10 @@ namespace Musify.Application.PlayLists.Responses
|
||||
playList.Pictures?.SmallName,
|
||||
playList.Pictures?.MediumName,
|
||||
playList.Pictures?.LargeName,
|
||||
playList.UserId,
|
||||
playList.Visibility,
|
||||
trackCount,
|
||||
durationSeconds,
|
||||
playList.CreatedAt,
|
||||
playList.UpdatedAt,
|
||||
coverTrackIds ?? []);
|
||||
|
||||
@@ -112,7 +112,22 @@ namespace Musify.Application.PlayLists
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
|
||||
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
|
||||
var totals = await database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(plt => plt.PlayListId == playListEntity.Id)
|
||||
.GroupBy(plt => plt.PlayListId)
|
||||
.Select(group => new
|
||||
{
|
||||
TrackCount = group.Count(),
|
||||
DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds)
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return PlayListApplicationResponse.FromEntity(
|
||||
playListEntity,
|
||||
totals?.TrackCount ?? 0,
|
||||
totals?.DurationSeconds ?? 0,
|
||||
coverTrackIds);
|
||||
}
|
||||
|
||||
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
|
||||
|
||||
@@ -11,8 +11,9 @@ ZITADEL_CLIENT_SECRET=your-web-app-client-secret
|
||||
AUTH_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||
AUTH_POST_LOGOUT_URI=http://localhost:5173/
|
||||
|
||||
# Secret used to derive the session cookie encryption key (any long random string)
|
||||
SESSION_SECRET=change-me-to-a-long-random-string
|
||||
# Secret used to derive the session cookie encryption key. Required, at least
|
||||
# 32 characters (e.g. `openssl rand -base64 48`).
|
||||
SESSION_SECRET=change-me-to-a-long-random-string-of-32-chars-or-more
|
||||
|
||||
# Backend API base URL
|
||||
API_BASE_URL=http://localhost:5111
|
||||
|
||||
@@ -50,7 +50,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
|
||||
|
||||
- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
|
||||
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl`; covers: `size-cover-xs` through `size-cover-hero`).
|
||||
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
|
||||
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
|
||||
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
|
||||
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
||||
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
|
||||
|
||||
Generated
+289
-65
@@ -36,41 +36,8 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
@@ -343,9 +310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -1152,6 +1119,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
|
||||
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.9",
|
||||
@@ -1203,6 +1171,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
|
||||
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1440,27 +1409,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
@@ -1565,12 +1513,30 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -1597,6 +1563,7 @@
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1658,6 +1625,7 @@
|
||||
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.64.0",
|
||||
"@typescript-eslint/types": "8.64.0",
|
||||
@@ -1765,6 +1733,7 @@
|
||||
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -1843,11 +1812,70 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
|
||||
"integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^1.2.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
|
||||
"integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1891,6 +1919,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -1923,6 +1961,16 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -2063,6 +2111,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -2082,6 +2137,7 @@
|
||||
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2323,6 +2379,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3007,9 +3073,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
@@ -3130,9 +3196,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -3160,6 +3226,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3293,6 +3360,7 @@
|
||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -3309,6 +3377,7 @@
|
||||
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -3479,6 +3548,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
@@ -3573,6 +3643,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -3596,6 +3673,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -3613,6 +3704,7 @@
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -3760,6 +3852,26 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -3824,6 +3936,7 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3885,6 +3998,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@@ -3976,6 +4090,99 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
|
||||
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.1",
|
||||
"chai": "^6.2.2",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
"magic-string": "^1.2.3",
|
||||
"obug": "^2.1.4",
|
||||
"picomatch": "^4.0.7",
|
||||
"std-env": "^4.2.0",
|
||||
"tinybench": "6.1.4",
|
||||
"tinyexec": "1.3.0",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.1",
|
||||
"@vitest/browser-preview": "5.0.1",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.1",
|
||||
"@vitest/coverage-v8": "5.0.1",
|
||||
"@vitest/ui": "5.0.1",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"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/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -3992,6 +4199,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"format": "prettier --write .",
|
||||
"lint:tokens": "node scripts/check-tokens.mjs",
|
||||
"lint": "prettier --check . && eslint . && npm run lint:tokens"
|
||||
"lint": "prettier --check . && eslint . && npm run lint:tokens && npm run test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -33,7 +35,8 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
|
||||
Vendored
+1
@@ -940,6 +940,7 @@ export interface components {
|
||||
smallImageKeyName: null | string;
|
||||
mediumImageKeyName: null | string;
|
||||
largeImageKeyName: null | string;
|
||||
ownerUserId: string;
|
||||
visibility: components["schemas"]["PlaylistVisibility"];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Logo from '../ui/primitives/Logo.svelte';
|
||||
|
||||
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
||||
|
||||
interface SidebarPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -19,11 +21,14 @@
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
playlists: SidebarPlaylist[];
|
||||
playlistCount: number;
|
||||
}
|
||||
|
||||
let { user, playlists }: Props = $props();
|
||||
let { user, playlists, playlistCount }: Props = $props();
|
||||
|
||||
const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count }));
|
||||
const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT));
|
||||
|
||||
const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count }));
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
@@ -61,10 +66,10 @@
|
||||
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
|
||||
<div class="flex items-center justify-between px-3 pb-1.5">
|
||||
<span class="text-eyebrow text-fg-2">Tus playlists</span>
|
||||
{#if playlists.length > 0}
|
||||
{#if visiblePlaylists.length > 0}
|
||||
<IconButton
|
||||
label="Crear playlist"
|
||||
tone="plain"
|
||||
plain
|
||||
size="xs"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
>
|
||||
@@ -74,7 +79,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
{#each visiblePlaylists as playlist (playlist.id)}
|
||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||
<ListRow
|
||||
href="/playlists/{playlist.id}"
|
||||
@@ -91,7 +96,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg-2"
|
||||
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
Crear tu primera lista
|
||||
|
||||
@@ -54,14 +54,7 @@
|
||||
</script>
|
||||
|
||||
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
|
||||
<IconButton
|
||||
label="Atrás"
|
||||
shape="round"
|
||||
tone="subtle"
|
||||
surface
|
||||
size="xs"
|
||||
onclick={() => history.back()}
|
||||
>
|
||||
<IconButton label="Atrás" shape="round" surface size="xs" onclick={() => history.back()}>
|
||||
<ChevronLeft class="size-icon-sm" />
|
||||
</IconButton>
|
||||
|
||||
@@ -91,9 +84,7 @@
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<div class="ml-auto shrink-0">
|
||||
{#if user}
|
||||
<AccountMenu {user} {accountUrl}>
|
||||
{#snippet trigger({ toggle, open })}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
|
||||
function seek(value: number) {
|
||||
if (player.current.duration > 0) player.seekFraction(value / player.current.duration);
|
||||
if (player.duration > 0) player.seekFraction(value / player.duration);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,20 +39,20 @@
|
||||
</span>
|
||||
<Slider
|
||||
value={player.progress}
|
||||
max={player.current.duration}
|
||||
max={player.duration}
|
||||
label="Barra de progreso"
|
||||
oninput={seek}
|
||||
variant="seek"
|
||||
/>
|
||||
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
|
||||
{fmtTime(player.current.duration)}
|
||||
{fmtTime(player.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
|
||||
<div class="flex w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5">
|
||||
<IconButton
|
||||
label={player.muted ? 'Activar sonido' : 'Silenciar'}
|
||||
tone="plain"
|
||||
plain
|
||||
size="xs"
|
||||
onclick={() => player.toggleMute()}
|
||||
>
|
||||
@@ -73,7 +73,7 @@
|
||||
/>
|
||||
<IconButton
|
||||
label="Cola de reproducción"
|
||||
tone="plain"
|
||||
plain
|
||||
size="xs"
|
||||
pressed={queuePanel.open}
|
||||
onclick={() => queuePanel.toggle()}
|
||||
|
||||
@@ -48,12 +48,12 @@
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
|
||||
<IconButton label="Cerrar cola" tone="plain" size="xs" onclick={() => queuePanel.close()}>
|
||||
<IconButton label="Cerrar cola" plain size="xs" onclick={() => queuePanel.close()}>
|
||||
<X class="size-icon-md" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{#if player.current.id}
|
||||
{#if player.current}
|
||||
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
|
||||
<div class="mb-6 surface-active rounded-control p-2.5">
|
||||
<MediaIdentity
|
||||
|
||||
@@ -13,27 +13,20 @@
|
||||
|
||||
let { compact = false }: Props = $props();
|
||||
|
||||
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
|
||||
const current = $derived(player.current);
|
||||
const isLiked = $derived(current !== null && liked.isLiked(current.id));
|
||||
|
||||
function toggleLike() {
|
||||
if (!player.current.id) return;
|
||||
liked.toggle({
|
||||
id: player.current.id,
|
||||
title: player.current.title,
|
||||
artist: player.current.artist,
|
||||
explicit: player.current.explicit,
|
||||
ownerUserId: player.current.ownerUserId,
|
||||
duration: player.current.duration
|
||||
});
|
||||
if (current) liked.toggle(current);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
||||
{#if player.current.id}
|
||||
{#if current}
|
||||
<Artwork
|
||||
trackIds={[player.current.id]}
|
||||
trackIds={[current.id]}
|
||||
size="sm"
|
||||
alt={player.current.title}
|
||||
alt={current.title}
|
||||
class="shrink-0 md:size-cover-md"
|
||||
/>
|
||||
{:else}
|
||||
@@ -41,21 +34,21 @@
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if player.current.explicit}
|
||||
{#if current?.explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-sm text-fg">{player.current.title}</span>
|
||||
<span class="truncate text-sm text-fg">{current?.title ?? ''}</span>
|
||||
</div>
|
||||
<ArtistLink
|
||||
name={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
name={current?.artist || '—'}
|
||||
ownerUserId={current?.ownerUserId}
|
||||
class="mt-0.5 text-xs text-fg-2"
|
||||
/>
|
||||
</div>
|
||||
{#if !compact}
|
||||
<IconButton
|
||||
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
|
||||
tone="plain"
|
||||
plain
|
||||
size="md"
|
||||
pressed={isLiked}
|
||||
onclick={toggleLike}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import Field from '../primitives/Field.svelte';
|
||||
import Input from '../primitives/Input.svelte';
|
||||
import Textarea from '../primitives/Textarea.svelte';
|
||||
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
||||
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
||||
|
||||
interface Props {
|
||||
action: string;
|
||||
@@ -58,10 +58,13 @@
|
||||
>
|
||||
{#snippet fields()}
|
||||
<Field label="Título" for="album-title">
|
||||
{#snippet hint()}
|
||||
<span class="ml-auto tabular-nums">{title.length}/{LIMITS.albumTitle}</span>
|
||||
{/snippet}
|
||||
<Input
|
||||
id="album-title"
|
||||
name="title"
|
||||
maxlength={200}
|
||||
maxlength={LIMITS.albumTitle}
|
||||
required
|
||||
bind:value={title}
|
||||
placeholder={titlePlaceholder}
|
||||
@@ -85,7 +88,7 @@
|
||||
<Textarea
|
||||
id="album-description"
|
||||
name="description"
|
||||
maxlength={256}
|
||||
maxlength={LIMITS.albumDescription}
|
||||
bind:value={description}
|
||||
placeholder="¿De qué va este álbum?"
|
||||
/>
|
||||
|
||||
@@ -81,10 +81,8 @@
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
{#if helperText}
|
||||
<p class="text-sm text-fg-2">{helperText}</p>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<div class="ml-auto flex shrink-0 items-center gap-3">
|
||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||
<Button type="submit" disabled={submitting || !canSubmit}>
|
||||
{submitting ? submittingLabel : submitLabel}
|
||||
|
||||
@@ -37,9 +37,16 @@
|
||||
function onInput(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
releasePreview();
|
||||
preview = URL.createObjectURL(file);
|
||||
onselect?.(file);
|
||||
}
|
||||
|
||||
function releasePreview() {
|
||||
if (preview !== '') URL.revokeObjectURL(preview);
|
||||
}
|
||||
|
||||
$effect(() => releasePreview);
|
||||
</script>
|
||||
|
||||
<label
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
import Textarea from '../primitives/Textarea.svelte';
|
||||
import SegmentedControl from '../primitives/SegmentedControl.svelte';
|
||||
|
||||
const NAME_MAX_LENGTH = 60;
|
||||
const DESCRIPTION_MAX_LENGTH = 300;
|
||||
import { LIMITS } from '$lib/validation';
|
||||
|
||||
interface Props {
|
||||
action: string;
|
||||
@@ -61,12 +60,12 @@
|
||||
<input type="hidden" name="visibility" value={visibility} />
|
||||
<Field label="Nombre" for="playlist-name">
|
||||
{#snippet hint()}
|
||||
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
||||
<span class="ml-auto tabular-nums">{name.length}/{LIMITS.playlistName}</span>
|
||||
{/snippet}
|
||||
<Input
|
||||
id="playlist-name"
|
||||
name="name"
|
||||
maxlength={NAME_MAX_LENGTH}
|
||||
maxlength={LIMITS.playlistName}
|
||||
required
|
||||
bind:value={name}
|
||||
placeholder={namePlaceholder}
|
||||
@@ -77,7 +76,7 @@
|
||||
<Textarea
|
||||
id="playlist-description"
|
||||
name="description"
|
||||
maxlength={DESCRIPTION_MAX_LENGTH}
|
||||
maxlength={LIMITS.playlistDescription}
|
||||
bind:value={description}
|
||||
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
||||
class="flex-1"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<a
|
||||
href="/user/{ownerUserId}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="block max-w-full truncate hover:text-fg-2 hover:underline {klass}"
|
||||
class="block max-w-full truncate hover:underline {klass}"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
size: Size;
|
||||
shape?: 'auto' | 'round';
|
||||
imageSize?: 'small' | 'medium' | 'large';
|
||||
fallback?: 'music' | 'gradient' | 'none';
|
||||
alt?: string;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
@@ -21,7 +20,6 @@
|
||||
size,
|
||||
shape = 'auto',
|
||||
imageSize,
|
||||
fallback = 'music',
|
||||
alt = '',
|
||||
class: klass = '',
|
||||
children
|
||||
@@ -116,7 +114,7 @@
|
||||
onerror={() => (singleFailed = true)}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else if fallback === 'music'}
|
||||
{:else}
|
||||
<div class="grid h-full w-full place-items-center bg-surface">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2">
|
||||
<path
|
||||
@@ -124,10 +122,6 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else if fallback === 'gradient'}
|
||||
<div class="h-full w-full bg-(image:--mf-cover-grad)"></div>
|
||||
{:else}
|
||||
<div class="h-full w-full bg-surface"></div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
barWidth?: number;
|
||||
paused?: boolean;
|
||||
overlay?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
size = 13,
|
||||
barWidth = 2,
|
||||
paused = false,
|
||||
overlay = false,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
let { paused = false, overlay = false, class: klass = '' }: Props = $props();
|
||||
|
||||
const delays = [0, 0.3, 0.6];
|
||||
</script>
|
||||
@@ -30,11 +22,11 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
|
||||
<div class="flex h-3.5 items-end justify-center gap-0.5 {klass}">
|
||||
{#each delays as delay (delay)}
|
||||
<span
|
||||
class="rounded-full bg-accent"
|
||||
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||
class="h-full w-0.5 rounded-full bg-accent"
|
||||
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
|
||||
interface Props {
|
||||
following: boolean;
|
||||
userId?: string;
|
||||
invalidateAll?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { following: initial, userId, invalidateAll = false, class: klass = '' }: Props = $props();
|
||||
let { following: initial, invalidateAll = false, class: klass = '' }: Props = $props();
|
||||
|
||||
let following = $state(untrack(() => initial));
|
||||
let submitting = $state(false);
|
||||
@@ -30,9 +29,6 @@
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#if userId}
|
||||
<input type="hidden" name="userId" value={userId} />
|
||||
{/if}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
class?: string;
|
||||
art?: Snippet;
|
||||
overlay?: Snippet;
|
||||
badge?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -34,8 +33,7 @@
|
||||
titleClass = '',
|
||||
class: klass = '',
|
||||
art,
|
||||
overlay,
|
||||
badge
|
||||
overlay
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -61,7 +59,6 @@
|
||||
<span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}"
|
||||
>{title}</span
|
||||
>
|
||||
{@render badge?.()}
|
||||
</div>
|
||||
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
<script lang="ts" module>
|
||||
import type { Track } from '$lib/types';
|
||||
|
||||
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
||||
|
||||
export interface TrackListTrack {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
ownerUserId?: string | number | null;
|
||||
isExplicit?: boolean;
|
||||
explicit?: boolean;
|
||||
duration?: number | string;
|
||||
listensCount?: number | string;
|
||||
addedAt?: string | number;
|
||||
uploadedAt?: string | number;
|
||||
}
|
||||
export type TrackListTrack = Track & { date?: string | number };
|
||||
|
||||
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
||||
added: { label: 'Añadida', width: '124px' },
|
||||
@@ -108,7 +99,7 @@
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
||||
{#each tracks as track, i (track.id)}
|
||||
{@const active = player.current.id === track.id}
|
||||
{@const active = player.currentId === track.id}
|
||||
|
||||
<div
|
||||
role="listitem"
|
||||
@@ -138,8 +129,6 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
use:pressable={() => onPlay(i)}
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => event.stopPropagation()}
|
||||
class="flex min-w-0 flex-1 text-left"
|
||||
>
|
||||
<MediaIdentity
|
||||
@@ -147,7 +136,7 @@
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
explicit={track.isExplicit ?? track.explicit ?? false}
|
||||
explicit={track.explicit}
|
||||
{active}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
@@ -160,26 +149,18 @@
|
||||
{#each columns as column (column)}
|
||||
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
||||
{#if column === 'plays'}
|
||||
{Number(track.listensCount ?? 0)}
|
||||
{:else if column === 'added' && track.addedAt}
|
||||
{fmtDate(track.addedAt)}
|
||||
{:else if column === 'uploaded' && track.uploadedAt}
|
||||
{fmtDate(track.uploadedAt)}
|
||||
{track.listensCount}
|
||||
{:else if track.date}
|
||||
{fmtDate(track.date)}
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
||||
{fmtTime(Number(track.duration))}
|
||||
{fmtTime(track.duration)}
|
||||
</span>
|
||||
{#if rowAction}
|
||||
{#if rowAction.action}
|
||||
<form
|
||||
method="POST"
|
||||
action={rowAction.action}
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<form method="POST" action={rowAction.action} use:enhance>
|
||||
<input type="hidden" name="trackId" value={track.id} />
|
||||
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
|
||||
<rowAction.icon class="size-icon-sm" />
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
{/if}
|
||||
<MenuItem
|
||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
||||
label={themeMode.current === 'dark' ? 'Modo claro' : 'Modo oscuro'}
|
||||
onclick={() => themeMode.toggle()}
|
||||
/>
|
||||
<form method="POST" action="/auth/logout" data-sveltekit-reload>
|
||||
|
||||
@@ -9,15 +9,19 @@
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
action: string;
|
||||
fields?: Record<string, string>;
|
||||
}
|
||||
|
||||
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
|
||||
let { open, onClose, title, description, confirmLabel, action, fields = {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Dialog {open} {onClose} {title} {description}>
|
||||
{#snippet actions()}
|
||||
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
|
||||
<form method="POST" {action}>
|
||||
{#each Object.entries(fields) as [name, value] (name)}
|
||||
<input type="hidden" {name} {value} />
|
||||
{/each}
|
||||
<Button type="submit" variant="danger">{confirmLabel}</Button>
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
type?: 'button' | 'submit';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
shape?: 'square' | 'round';
|
||||
tone?: 'muted' | 'subtle' | 'plain';
|
||||
plain?: boolean;
|
||||
pressed?: boolean;
|
||||
surface?: boolean;
|
||||
revealOnHover?: boolean;
|
||||
@@ -22,7 +22,7 @@
|
||||
type = 'button',
|
||||
size = 'sm',
|
||||
shape = 'square',
|
||||
tone = 'muted',
|
||||
plain = false,
|
||||
pressed,
|
||||
surface = false,
|
||||
revealOnHover = false,
|
||||
@@ -40,18 +40,12 @@
|
||||
lg: 'size-10'
|
||||
};
|
||||
|
||||
const TEXT: Record<'muted' | 'subtle' | 'plain', string> = {
|
||||
muted: 'text-fg-2 hover:text-fg',
|
||||
subtle: 'text-fg-2 hover:text-fg',
|
||||
plain: 'text-fg-2 hover:text-fg'
|
||||
};
|
||||
|
||||
const background = $derived(
|
||||
surface ? 'bg-surface-2 hover:bg-surface-hover' : tone === 'plain' ? '' : 'hover:bg-hover'
|
||||
surface ? 'bg-surface-2 hover:bg-surface-hover' : plain ? '' : 'hover:bg-hover'
|
||||
);
|
||||
|
||||
const classes = $derived(
|
||||
`grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : TEXT[tone]} ${background} ${
|
||||
`grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : 'text-fg-2 hover:text-fg'} ${background} ${
|
||||
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
|
||||
} ${klass}`
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
|
||||
option.value
|
||||
? 'text-ink'
|
||||
: 'text-fg-2 hover:text-fg-2'}"
|
||||
: 'text-fg-2'}"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
export const EXPLORE_PAGE_SIZE = 27;
|
||||
export const MAX_PAGE_SIZE = 100;
|
||||
export const SEARCH_DEBOUNCE_MS = 450;
|
||||
export const SEARCH_MIN_LENGTH = 2;
|
||||
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
|
||||
export const ALBUMS_PAGE_SIZE = 50;
|
||||
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||
export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
|
||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||
export const LIBRARY_PAGE_SIZE = 50;
|
||||
export const PLAYLISTS_PAGE_SIZE = 50;
|
||||
export const PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE = 500;
|
||||
export const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
|
||||
export const COUNT_ONLY_PAGE_SIZE = 1;
|
||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||
export const EXPLORE_ALBUMS_LIMIT = 18;
|
||||
export const HOME_LATEST_PAGE_SIZE = 10;
|
||||
export const HOME_MIXES_LIMIT = 6;
|
||||
export const HOME_SHELF_LIMIT = 6;
|
||||
export const HOME_CONTINUE_LIMIT = 10;
|
||||
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
||||
export const HOME_POPULAR_MAX = 8;
|
||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
||||
export const EXPLORE_USERS_LIMIT = 12;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||
import type { Paged, Track } from '$lib/types';
|
||||
|
||||
export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> {
|
||||
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
||||
async function fetchTracks(url: string): Promise<Track[]> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
||||
return toQueueItems(data.items ?? []);
|
||||
return ((await res.json()) as Paged<Track>).items;
|
||||
}
|
||||
|
||||
export const fetchAlbumTracks = (albumId: string) => fetchTracks(`/api/albums/${albumId}/tracks`);
|
||||
|
||||
export const fetchPlaylistTracks = (playlistId: string) =>
|
||||
fetchTracks(`/api/playlists/${playlistId}/tracks`);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { appendUnique, shuffle } from './collections';
|
||||
|
||||
describe('appendUnique', () => {
|
||||
const keyOf = (item: { id: number }) => item.id;
|
||||
it('appends only unseen keys', () => {
|
||||
const result = appendUnique([{ id: 1 }], [{ id: 1 }, { id: 2 }, { id: 2 }, { id: 3 }], keyOf);
|
||||
expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
});
|
||||
it('returns the same array when nothing is new', () => {
|
||||
const current = [{ id: 1 }];
|
||||
expect(appendUnique(current, [{ id: 1 }], keyOf)).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffle', () => {
|
||||
it('keeps the same elements and does not mutate the input', () => {
|
||||
const input = [1, 2, 3, 4, 5, 6];
|
||||
const result = shuffle(input);
|
||||
expect([...result].sort()).toEqual(input);
|
||||
expect(input).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findConflict, genreHue } from './genres';
|
||||
|
||||
describe('genreHue', () => {
|
||||
it('is stable and within 0-359', () => {
|
||||
for (const genre of ['rock', 'something-unknown', 'ñandú']) {
|
||||
const hue = genreHue(genre);
|
||||
expect(hue).toBe(genreHue(genre));
|
||||
expect(hue).toBeGreaterThanOrEqual(0);
|
||||
expect(hue).toBeLessThan(360);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findConflict', () => {
|
||||
const options = [
|
||||
{ genre: 'a', incompatibleWith: ['b'] },
|
||||
{ genre: 'b', incompatibleWith: ['a'] },
|
||||
{ genre: 'c', incompatibleWith: [] }
|
||||
];
|
||||
it('returns the first incompatible pair', () => {
|
||||
expect(findConflict(['c', 'a', 'b'], options)).toEqual(['a', 'b']);
|
||||
});
|
||||
it('returns undefined when compatible or unknown', () => {
|
||||
expect(findConflict(['a', 'c'], options)).toBeUndefined();
|
||||
expect(findConflict(['x', 'y'], options)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { components } from '$lib/api/schema';
|
||||
import type { ApiTrackLike } from '$lib/player/player.svelte';
|
||||
|
||||
export type Mix = components['schemas']['MixApplicationResponse'];
|
||||
export type MixItem = components['schemas']['MixItemApplicationResponse'];
|
||||
|
||||
export function mixItemKey(item: MixItem): string {
|
||||
return `mf:${item.trackId}`;
|
||||
}
|
||||
|
||||
export function mixItemTrack(item: MixItem): ApiTrackLike {
|
||||
return {
|
||||
id: item.trackId,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
duration: item.durationSeconds,
|
||||
listensCount: item.listensCount
|
||||
};
|
||||
}
|
||||
@@ -1,21 +1,14 @@
|
||||
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { Track } from '$lib/types';
|
||||
|
||||
export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] {
|
||||
export function mergeRecentlyPlayed(history: Track[], limit: number): Track[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: QueueItem[] = [];
|
||||
const merged: Track[] = [];
|
||||
|
||||
for (const t of player.recentlyPlayed) {
|
||||
const id = String(t.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({ ...t, id });
|
||||
}
|
||||
|
||||
for (const item of toQueueItems(history)) {
|
||||
const id = String(item.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({ ...item, id });
|
||||
for (const track of [...player.recentlyPlayed, ...history]) {
|
||||
if (seen.has(track.id)) continue;
|
||||
seen.add(track.id);
|
||||
merged.push(track);
|
||||
}
|
||||
|
||||
return merged.slice(0, limit);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findTopResult } from './search';
|
||||
|
||||
const empty = { tracks: [], albums: [], playlists: [], users: [] };
|
||||
|
||||
describe('findTopResult', () => {
|
||||
it('prefers prefix matches by kind: user, track, album, playlist', () => {
|
||||
const result = findTopResult({
|
||||
query: 'Ab',
|
||||
users: [{ name: 'Zed' }, { name: 'abel' }],
|
||||
tracks: [{ title: 'Abc' }],
|
||||
albums: [{ title: 'Abd' }],
|
||||
playlists: [{ name: 'Abe' }]
|
||||
});
|
||||
expect(result).toEqual({ kind: 'user', user: { name: 'abel' } });
|
||||
});
|
||||
it('matches case-insensitively and ignores surrounding spaces', () => {
|
||||
const result = findTopResult({ ...empty, query: ' SO ', tracks: [{ title: 'song' }] });
|
||||
expect(result).toEqual({ kind: 'track', track: { title: 'song' } });
|
||||
});
|
||||
it('falls back to the first available result', () => {
|
||||
const result = findTopResult({ ...empty, query: 'zzz', albums: [{ title: 'Other' }] });
|
||||
expect(result).toEqual({ kind: 'album', album: { title: 'Other' } });
|
||||
});
|
||||
it('returns null without results', () => {
|
||||
expect(findTopResult({ ...empty, query: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,4 @@
|
||||
import { toQueueItems, type ApiTrackLike } from './player.svelte';
|
||||
|
||||
export interface LikedTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
listensCount?: number | string;
|
||||
duration?: number | string;
|
||||
likedAt: number;
|
||||
}
|
||||
|
||||
type LikeToggleInput = {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
duration?: number | string;
|
||||
};
|
||||
import type { LikedTrack, Track } from '$lib/types';
|
||||
|
||||
class LikedStore {
|
||||
entries = $state<Record<string, LikedTrack>>({});
|
||||
@@ -27,35 +7,23 @@ class LikedStore {
|
||||
count = $derived(Object.keys(this.entries).length);
|
||||
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
||||
|
||||
hydrate(tracks: (ApiTrackLike & { createdAt: string; duration: number | string })[]) {
|
||||
hydrate(tracks: LikedTrack[]) {
|
||||
if (this.hydrated) return;
|
||||
this.hydrated = true;
|
||||
|
||||
const items = toQueueItems(tracks);
|
||||
const next: Record<string, LikedTrack> = {};
|
||||
items.forEach((item, i) => {
|
||||
const id = String(item.id);
|
||||
next[id] = {
|
||||
...item,
|
||||
id,
|
||||
duration: tracks[i].duration,
|
||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
||||
};
|
||||
});
|
||||
this.entries = next;
|
||||
this.entries = Object.fromEntries(tracks.map((track) => [track.id, track]));
|
||||
}
|
||||
|
||||
isLiked(id: string | number): boolean {
|
||||
return String(id) in this.entries;
|
||||
isLiked(id: string): boolean {
|
||||
return id in this.entries;
|
||||
}
|
||||
|
||||
async toggle(track: LikeToggleInput) {
|
||||
const id = String(track.id);
|
||||
async toggle(track: Track) {
|
||||
const id = track.id;
|
||||
const before = this.entries;
|
||||
const wasLiked = id in before;
|
||||
const next = { ...before };
|
||||
if (wasLiked) delete next[id];
|
||||
else next[id] = { ...track, id, likedAt: Date.now() };
|
||||
else next[id] = { ...track, likedAt: Date.now() };
|
||||
this.entries = next;
|
||||
|
||||
try {
|
||||
@@ -66,7 +34,10 @@ class LikedStore {
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to toggle like');
|
||||
} catch {
|
||||
this.entries = before;
|
||||
const reverted = { ...this.entries };
|
||||
if (wasLiked) reverted[id] = before[id];
|
||||
else delete reverted[id];
|
||||
this.entries = reverted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,24 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
||||
import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||
import { extractAccent } from '$lib/theme/palette';
|
||||
import { shuffle } from '$lib/data/collections';
|
||||
import { dialog } from '$lib/state/dialog.svelte';
|
||||
import { ListenTracker } from './listenTracker';
|
||||
import { readStorage, writeStorage } from '$lib/utils/storage';
|
||||
import type { Track } from '$lib/types';
|
||||
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
||||
|
||||
export interface PlayerTrack {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: number;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
listensCount?: number | string;
|
||||
export function isQueueCurrent(items: { id: string }[]): boolean {
|
||||
return items.some((item) => item.id === player.currentId);
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string | number;
|
||||
name: string;
|
||||
trackIds: (string | number)[];
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
duration?: number | string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
listensCount?: number | string;
|
||||
}
|
||||
|
||||
export interface ApiTrackLike {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
duration?: number | string;
|
||||
isExplicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
listensCount?: number | string;
|
||||
}
|
||||
|
||||
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||
return tracks.map((track) => ({
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
artist: track.artist ?? undefined,
|
||||
duration: track.duration,
|
||||
explicit: track.isExplicit,
|
||||
ownerUserId: track.ownerUserId,
|
||||
listensCount: track.listensCount
|
||||
}));
|
||||
}
|
||||
|
||||
export function toQueueItem(track: ApiTrackLike): QueueItem {
|
||||
return toQueueItems([track])[0];
|
||||
}
|
||||
|
||||
export function trackFromQueueItem(item: QueueItem): ApiTrackLike {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
duration: item.duration,
|
||||
isExplicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId,
|
||||
listensCount: item.listensCount
|
||||
};
|
||||
}
|
||||
|
||||
export function isQueueCurrent(items: { id: string | number }[]): boolean {
|
||||
return items.some((item) => item.id === player.current.id);
|
||||
}
|
||||
|
||||
export function playAllOrToggle(items: QueueItem[]) {
|
||||
export function playAllOrToggle(items: Track[]) {
|
||||
if (items.length === 0) return;
|
||||
if (isQueueCurrent(items)) player.toggle();
|
||||
else player.playQueue(items, 0);
|
||||
}
|
||||
|
||||
export function playShuffled(items: QueueItem[]) {
|
||||
export function playShuffled(items: Track[]) {
|
||||
if (items.length === 0) return;
|
||||
player.playQueue(shuffle(items), 0);
|
||||
}
|
||||
@@ -94,36 +33,30 @@ async function readErrorMessage(res: Response): Promise<string> {
|
||||
}
|
||||
|
||||
const RECENT_LIMIT = 15;
|
||||
const DEFAULT_VOLUME = 100;
|
||||
const VOLUME_KEY = 'musify.player.volume';
|
||||
const MUTED_KEY = 'musify.player.muted';
|
||||
|
||||
const EMPTY: PlayerTrack = {
|
||||
id: '',
|
||||
title: '',
|
||||
artist: '',
|
||||
duration: 0
|
||||
};
|
||||
function clampVolume(value: number): number {
|
||||
return Math.min(100, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
function toTrack(item: QueueItem): PlayerTrack {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
artist: item.artist ?? '',
|
||||
duration: Number(item.duration) || 0,
|
||||
explicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId,
|
||||
listensCount: item.listensCount
|
||||
};
|
||||
function loadVolume(): number {
|
||||
const stored = readStorage(VOLUME_KEY);
|
||||
if (stored === null || stored.trim() === '') return DEFAULT_VOLUME;
|
||||
const parsed = Number(stored);
|
||||
return Number.isFinite(parsed) ? clampVolume(parsed) : DEFAULT_VOLUME;
|
||||
}
|
||||
|
||||
class PlayerState {
|
||||
tracks = $state<PlayerTrack[]>([]);
|
||||
currentId = $state<string | number | null>(null);
|
||||
tracks = $state<Track[]>([]);
|
||||
currentId = $state<string | null>(null);
|
||||
playing = $state(false);
|
||||
progress = $state(0);
|
||||
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
|
||||
muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false);
|
||||
volume = $state(browser ? loadVolume() : DEFAULT_VOLUME);
|
||||
muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false);
|
||||
loading = $state(false);
|
||||
recentlyPlayed = $state<PlayerTrack[]>([]);
|
||||
playlists = $state<Playlist[]>([]);
|
||||
recentlyPlayed = $state<Track[]>([]);
|
||||
shuffle = $state(false);
|
||||
repeat = $state(false);
|
||||
|
||||
@@ -131,20 +64,17 @@ class PlayerState {
|
||||
|
||||
#audio: HTMLAudioElement | null = null;
|
||||
#loadToken = 0;
|
||||
#accentCache = new Map<string, Accent>();
|
||||
#accentCache = new Map<string, string>();
|
||||
#rafId: number | null = null;
|
||||
#listen = new ListenTracker();
|
||||
|
||||
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
|
||||
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? null);
|
||||
accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
|
||||
duration = $derived(this.current?.duration ?? 0);
|
||||
progressPercent = $derived(
|
||||
this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0
|
||||
this.duration > 0 ? Math.min(100, (this.progress / this.duration) * 100) : 0
|
||||
);
|
||||
|
||||
get isPlaying() {
|
||||
return this.playing;
|
||||
}
|
||||
|
||||
#audioEl(): HTMLAudioElement | null {
|
||||
if (!browser) return null;
|
||||
if (this.#audio) return this.#audio;
|
||||
@@ -182,6 +112,10 @@ class PlayerState {
|
||||
audio.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!this.shuffle && isLastIndex(this.tracks.length, this.#index())) {
|
||||
this.#stopAtEnd(audio);
|
||||
return;
|
||||
}
|
||||
this.next();
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
@@ -199,6 +133,13 @@ class PlayerState {
|
||||
return audio;
|
||||
}
|
||||
|
||||
#stopAtEnd(audio: HTMLAudioElement) {
|
||||
audio.currentTime = 0;
|
||||
this.progress = 0;
|
||||
this.playing = false;
|
||||
this.#syncMediaSessionPlaybackState();
|
||||
}
|
||||
|
||||
#setupMediaSession(audio: HTMLAudioElement) {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
|
||||
@@ -212,15 +153,11 @@ class PlayerState {
|
||||
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
|
||||
}
|
||||
|
||||
#syncMediaSessionMetadata(track: PlayerTrack) {
|
||||
#syncMediaSessionMetadata(track: Track) {
|
||||
if (!browser || !('mediaSession' in navigator)) return;
|
||||
if (!track.id) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
return;
|
||||
}
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
artist: track.artist ?? '',
|
||||
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
|
||||
});
|
||||
}
|
||||
@@ -262,26 +199,24 @@ class PlayerState {
|
||||
return this.tracks.findIndex((t) => t.id === this.currentId);
|
||||
}
|
||||
|
||||
#pushRecent(track: PlayerTrack) {
|
||||
if (!track.id) return;
|
||||
#pushRecent(track: Track) {
|
||||
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
|
||||
0,
|
||||
RECENT_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
async #applyAccent(id: string | number) {
|
||||
const key = String(id);
|
||||
const cached = this.#accentCache.get(key);
|
||||
async #applyAccent(id: string) {
|
||||
const cached = this.#accentCache.get(id);
|
||||
if (cached) {
|
||||
this.accentColor = cached.accent;
|
||||
this.accentColor = cached;
|
||||
return;
|
||||
}
|
||||
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
|
||||
if (this.currentId !== id) return;
|
||||
if (result) {
|
||||
this.#accentCache.set(key, result);
|
||||
this.accentColor = result.accent;
|
||||
this.#accentCache.set(id, result);
|
||||
this.accentColor = result;
|
||||
} else {
|
||||
this.accentColor = null;
|
||||
}
|
||||
@@ -306,18 +241,14 @@ class PlayerState {
|
||||
this.#listen.begin(listenId);
|
||||
await audio.play();
|
||||
this.loading = false;
|
||||
this.#pushRecent(this.current);
|
||||
} catch (exception) {
|
||||
console.error('playback failed', exception);
|
||||
if (track) this.#pushRecent(track);
|
||||
} catch (err) {
|
||||
console.error('playback failed', err);
|
||||
if (token !== this.#loadToken) return;
|
||||
this.loading = false;
|
||||
this.playing = false;
|
||||
if (exception instanceof DOMException && exception.name === 'AbortError') return;
|
||||
this.#fail(
|
||||
exception instanceof Error && !(exception instanceof DOMException)
|
||||
? exception.message
|
||||
: undefined
|
||||
);
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
this.#fail(err instanceof Error && !(err instanceof DOMException) ? err.message : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,79 +290,44 @@ class PlayerState {
|
||||
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
hydrate(item: ApiTrackLike) {
|
||||
hydrate(track: Track) {
|
||||
if (this.currentId !== null || this.tracks.length > 0) return;
|
||||
const track = toTrack(toQueueItem(item));
|
||||
this.tracks = [track];
|
||||
this.currentId = track.id;
|
||||
this.#applyAccent(track.id);
|
||||
this.#syncMediaSessionMetadata(track);
|
||||
}
|
||||
|
||||
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
|
||||
return ids
|
||||
.map((id) => this.tracks.find((t) => t.id === id))
|
||||
.filter((t): t is PlayerTrack => Boolean(t));
|
||||
}
|
||||
|
||||
playQueue(list: QueueItem[], startIndex = 0) {
|
||||
playQueue(list: Track[], startIndex = 0) {
|
||||
if (list.length === 0) return;
|
||||
this.tracks = list.map(toTrack);
|
||||
this.tracks = [...list];
|
||||
const start = this.tracks[startIndex] ?? this.tracks[0];
|
||||
this.currentId = start.id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
playOrToggle(list: QueueItem[], index: number) {
|
||||
playOrToggle(list: Track[], index: number) {
|
||||
if (index < 0 || index >= list.length) return;
|
||||
if (this.currentId === list[index].id) this.toggle();
|
||||
else this.playQueue(list, index);
|
||||
}
|
||||
|
||||
playNextItem(item: QueueItem) {
|
||||
this.playNext([item]);
|
||||
}
|
||||
|
||||
playNext(items: QueueItem[]) {
|
||||
playNext(items: Track[]) {
|
||||
if (items.length === 0) return;
|
||||
if (this.currentId === null) {
|
||||
this.playQueue(items, 0);
|
||||
return;
|
||||
}
|
||||
const tracks = [...this.tracks];
|
||||
tracks.splice(this.#index() + 1, 0, ...items.map(toTrack));
|
||||
this.tracks = tracks;
|
||||
this.tracks = insertNext(this.tracks, this.#index(), items);
|
||||
}
|
||||
|
||||
appendToQueue(items: QueueItem[]) {
|
||||
appendToQueue(items: Track[]) {
|
||||
if (items.length === 0) return;
|
||||
if (this.currentId === null) {
|
||||
this.playQueue(items, 0);
|
||||
return;
|
||||
}
|
||||
this.tracks = [...this.tracks, ...items.map(toTrack)];
|
||||
}
|
||||
|
||||
playTrack(id: string | number) {
|
||||
if (this.currentId === id) {
|
||||
this.toggle();
|
||||
return;
|
||||
}
|
||||
const known =
|
||||
this.tracks.find((t) => t.id === id) ?? this.recentlyPlayed.find((t) => t.id === id);
|
||||
if (known && !this.tracks.some((t) => t.id === id)) {
|
||||
this.tracks = [known];
|
||||
}
|
||||
if (!this.tracks.some((t) => t.id === id)) return;
|
||||
this.currentId = id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
playPlaylist(id: string | number) {
|
||||
const playlist = this.playlists.find((p) => p.id === id);
|
||||
if (!playlist) return;
|
||||
const items = this.playlistTracks(playlist.trackIds);
|
||||
if (items.length > 0) this.playQueue(items, 0);
|
||||
this.tracks = append(this.tracks, items);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
@@ -454,19 +350,13 @@ class PlayerState {
|
||||
this.muted = value;
|
||||
const audio = this.#audioEl();
|
||||
if (audio) audio.muted = value;
|
||||
if (browser) localStorage.setItem('player.muted', String(value));
|
||||
if (browser) writeStorage(MUTED_KEY, String(value));
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.tracks.length === 0) return;
|
||||
const idx = this.#index();
|
||||
let nextIdx = (idx + 1) % this.tracks.length;
|
||||
if (this.shuffle && this.tracks.length > 1) {
|
||||
do {
|
||||
nextIdx = Math.floor(Math.random() * this.tracks.length);
|
||||
} while (nextIdx === idx);
|
||||
}
|
||||
this.currentId = this.tracks[nextIdx].id;
|
||||
this.currentId = this.tracks[nextIndex(this.tracks.length, idx, this.shuffle)].id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
@@ -490,17 +380,11 @@ class PlayerState {
|
||||
}
|
||||
|
||||
moveQueueItem(from: number, insertAt: number) {
|
||||
if (from < 0 || from >= this.tracks.length) return;
|
||||
const target = insertAt > from ? insertAt - 1 : insertAt;
|
||||
if (target === from) return;
|
||||
const tracks = [...this.tracks];
|
||||
const [item] = tracks.splice(from, 1);
|
||||
tracks.splice(target, 0, item);
|
||||
this.tracks = tracks;
|
||||
this.tracks = move(this.tracks, from, insertAt);
|
||||
}
|
||||
|
||||
clearUpcoming() {
|
||||
this.tracks = this.currentId === null ? [] : [this.current];
|
||||
this.tracks = this.current ? [this.current] : [];
|
||||
}
|
||||
|
||||
previous() {
|
||||
@@ -511,26 +395,24 @@ class PlayerState {
|
||||
return;
|
||||
}
|
||||
const idx = this.#index();
|
||||
this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id;
|
||||
this.currentId = this.tracks[previousIndex(this.tracks.length, idx)].id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
seekFraction(fraction: number) {
|
||||
const audio = this.#audioEl();
|
||||
const clamped = Math.min(1, Math.max(0, fraction));
|
||||
const target = clamped * this.current.duration;
|
||||
const target = clamped * this.duration;
|
||||
this.progress = target;
|
||||
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
|
||||
}
|
||||
|
||||
setVolume(value: number) {
|
||||
this.volume = Math.min(100, Math.max(0, Math.round(value)));
|
||||
this.volume = Number.isFinite(value) ? clampVolume(value) : this.volume;
|
||||
if (this.muted && this.volume > 0) this.#applyMuted(false);
|
||||
const audio = this.#audioEl();
|
||||
if (audio) {
|
||||
audio.volume = this.volume / 100;
|
||||
localStorage.setItem('player.volume', String(this.volume));
|
||||
}
|
||||
if (audio) audio.volume = this.volume / 100;
|
||||
if (browser) writeStorage(VOLUME_KEY, String(this.volume));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
||||
|
||||
const t = (id: string) => ({ id });
|
||||
const ids = (queue: { id: string | number }[]) => queue.map((item) => item.id);
|
||||
|
||||
describe('insertNext', () => {
|
||||
it('inserts right after the current track without mutating', () => {
|
||||
const queue = ['a', 'b', 'c'].map(t);
|
||||
expect(ids(insertNext(queue, 0, ['x', 'y'].map(t)))).toEqual(['a', 'x', 'y', 'b', 'c']);
|
||||
expect(ids(queue)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
it('inserts at the start when there is no current track', () => {
|
||||
expect(ids(insertNext([t('a')], -1, [t('x')]))).toEqual(['x', 'a']);
|
||||
});
|
||||
it('moves tracks already queued instead of duplicating them', () => {
|
||||
const queue = ['a', 'b', 'c', 'd'].map(t);
|
||||
expect(ids(insertNext(queue, 1, [t('d'), t('a')]))).toEqual(['b', 'd', 'a', 'c']);
|
||||
});
|
||||
it('ignores the current track', () => {
|
||||
const queue = ['a', 'b'].map(t);
|
||||
expect(ids(insertNext(queue, 0, [t('a')]))).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('append', () => {
|
||||
it('adds new items at the end', () => {
|
||||
expect(ids(append([t('a')], ['b', 'c'].map(t)))).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
it('skips tracks already in the queue and repeated ones', () => {
|
||||
expect(ids(append(['a', 'b'].map(t), ['b', 'c', 'c'].map(t)))).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
it('returns the same queue when nothing is added', () => {
|
||||
const queue = [t('a')];
|
||||
expect(append(queue, [t('a')])).toBe(queue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('move', () => {
|
||||
const queue = ['a', 'b', 'c', 'd'];
|
||||
it('moves an item forward using insert position semantics', () => {
|
||||
expect(move(queue, 0, 3)).toEqual(['b', 'c', 'a', 'd']);
|
||||
});
|
||||
it('moves an item backward', () => {
|
||||
expect(move(queue, 3, 1)).toEqual(['a', 'd', 'b', 'c']);
|
||||
});
|
||||
it('is a no-op when the target equals the origin', () => {
|
||||
expect(move(queue, 1, 1)).toBe(queue);
|
||||
expect(move(queue, 1, 2)).toBe(queue);
|
||||
});
|
||||
it('ignores out-of-range origins', () => {
|
||||
expect(move(queue, -1, 0)).toBe(queue);
|
||||
expect(move(queue, 4, 0)).toBe(queue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextIndex', () => {
|
||||
it('advances and wraps around', () => {
|
||||
expect(nextIndex(3, 0, false)).toBe(1);
|
||||
expect(nextIndex(3, 2, false)).toBe(0);
|
||||
});
|
||||
it('never repeats the current index when shuffling', () => {
|
||||
const values = [0.4, 0.5, 0.9];
|
||||
let call = 0;
|
||||
expect(nextIndex(3, 1, true, () => values[call++])).toBe(2);
|
||||
expect(call).toBe(3);
|
||||
});
|
||||
it('ignores shuffle with a single track', () => {
|
||||
expect(nextIndex(1, 0, true)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previousIndex', () => {
|
||||
it('steps back and wraps around', () => {
|
||||
expect(previousIndex(3, 2)).toBe(1);
|
||||
expect(previousIndex(3, 0)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLastIndex', () => {
|
||||
it('detects the end of the queue', () => {
|
||||
expect(isLastIndex(3, 2)).toBe(true);
|
||||
expect(isLastIndex(3, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
interface Identified {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export function insertNext<T extends Identified>(
|
||||
queue: T[],
|
||||
currentIndex: number,
|
||||
items: T[]
|
||||
): T[] {
|
||||
const currentId = queue[currentIndex]?.id;
|
||||
const incoming = items.filter((item) => item.id !== currentId);
|
||||
const moving = new Set(incoming.map((item) => item.id));
|
||||
const rest = queue.filter((item) => !moving.has(item.id));
|
||||
const anchor = currentId === undefined ? -1 : rest.findIndex((item) => item.id === currentId);
|
||||
return [...rest.slice(0, anchor + 1), ...incoming, ...rest.slice(anchor + 1)];
|
||||
}
|
||||
|
||||
export function append<T extends Identified>(queue: T[], items: T[]): T[] {
|
||||
const present = new Set(queue.map((item) => item.id));
|
||||
const added = items.filter((item) => {
|
||||
if (present.has(item.id)) return false;
|
||||
present.add(item.id);
|
||||
return true;
|
||||
});
|
||||
return added.length > 0 ? [...queue, ...added] : queue;
|
||||
}
|
||||
|
||||
export function move<T>(queue: T[], from: number, insertAt: number): T[] {
|
||||
if (from < 0 || from >= queue.length) return queue;
|
||||
const target = insertAt > from ? insertAt - 1 : insertAt;
|
||||
if (target === from) return queue;
|
||||
const result = [...queue];
|
||||
const [item] = result.splice(from, 1);
|
||||
result.splice(target, 0, item);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isLastIndex(length: number, index: number): boolean {
|
||||
return index >= length - 1;
|
||||
}
|
||||
|
||||
export function nextIndex(
|
||||
length: number,
|
||||
index: number,
|
||||
shuffle: boolean,
|
||||
random: () => number = Math.random
|
||||
): number {
|
||||
if (shuffle && length > 1) {
|
||||
let next: number;
|
||||
do {
|
||||
next = Math.floor(random() * length);
|
||||
} while (next === index);
|
||||
return next;
|
||||
}
|
||||
return (index + 1) % length;
|
||||
}
|
||||
|
||||
export function previousIndex(length: number, index: number): number {
|
||||
return (index - 1 + length) % length;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import { error, fail, redirect, type ActionFailure } from '@sveltejs/kit';
|
||||
import { error, fail, redirect, type ActionFailure, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { paths } from '$lib/api/schema';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import { apiConfig } from '$lib/server/config';
|
||||
@@ -17,9 +17,18 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
export type ApiClient = ReturnType<typeof createApiClient>;
|
||||
|
||||
export function apiFor({ fetch, locals }: Pick<RequestEvent, 'fetch' | 'locals'>): ApiClient {
|
||||
return createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
}
|
||||
|
||||
export function loginRedirect(url: URL): never {
|
||||
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
}
|
||||
|
||||
export function requireUser(locals: App.Locals, url: URL): SessionUser {
|
||||
if (!locals.user)
|
||||
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
if (!locals.user) loginRedirect(url);
|
||||
return locals.user;
|
||||
}
|
||||
|
||||
@@ -29,16 +38,42 @@ export function optionalUser(
|
||||
allowAnonymousListening: boolean
|
||||
): SessionUser | null {
|
||||
if (locals.user) return locals.user;
|
||||
if (!allowAnonymousListening)
|
||||
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
if (!allowAnonymousListening) loginRedirect(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function requireAccessTokenAction(
|
||||
locals: App.Locals,
|
||||
message = 'Inicia sesión.'
|
||||
): string | ActionFailure<{ message: string }> {
|
||||
return locals.accessToken ?? fail(401, { message });
|
||||
export function formString(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? '');
|
||||
}
|
||||
|
||||
export function formFile(form: FormData, key: string): File | null {
|
||||
const value = form.get(key);
|
||||
return value instanceof File && value.size > 0 ? value : null;
|
||||
}
|
||||
|
||||
interface ActionContext {
|
||||
api: ApiClient;
|
||||
form: FormData;
|
||||
params: Record<string, string>;
|
||||
locals: App.Locals;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export function authedAction<R>(
|
||||
handler: (context: ActionContext) => R | Promise<R>,
|
||||
unauthorizedMessage = 'Inicia sesión.'
|
||||
) {
|
||||
return async (event: RequestEvent) => {
|
||||
if (!event.locals.accessToken) return fail(401, { message: unauthorizedMessage });
|
||||
const form = await event.request.formData();
|
||||
return handler({
|
||||
api: apiFor(event),
|
||||
form,
|
||||
params: event.params as Record<string, string>,
|
||||
locals: event.locals,
|
||||
url: event.url
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function unwrapOrError<T>(
|
||||
@@ -70,9 +105,18 @@ export function apiErrorDetail(error: unknown): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function unwrapOrFail(
|
||||
result: { error?: unknown },
|
||||
message: string
|
||||
): ActionFailure<{ message: string; detail?: string }> | undefined {
|
||||
type Failure = ActionFailure<{ message: string; detail?: string }>;
|
||||
|
||||
export function failOnError(result: { error?: unknown }, message: string): Failure | undefined {
|
||||
return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : undefined;
|
||||
}
|
||||
|
||||
export function requireData<T>(
|
||||
result: { data?: T; error?: unknown },
|
||||
message: string
|
||||
): { data: T; failure?: undefined } | { data?: undefined; failure: Failure } {
|
||||
if (result.error || result.data === undefined) {
|
||||
return { failure: fail(502, { message, detail: apiErrorDetail(result.error) }) };
|
||||
}
|
||||
return { data: result.data };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeReturnTo } from './auth';
|
||||
|
||||
describe('safeReturnTo', () => {
|
||||
it('keeps local paths', () => {
|
||||
expect(safeReturnTo('/')).toBe('/');
|
||||
expect(safeReturnTo('/playlists/1?tab=x#top')).toBe('/playlists/1?tab=x#top');
|
||||
});
|
||||
it('falls back to / for missing values', () => {
|
||||
expect(safeReturnTo(null)).toBe('/');
|
||||
expect(safeReturnTo(undefined)).toBe('/');
|
||||
expect(safeReturnTo('')).toBe('/');
|
||||
});
|
||||
it('rejects absolute and protocol-relative URLs', () => {
|
||||
expect(safeReturnTo('https://example.com')).toBe('/');
|
||||
expect(safeReturnTo('//example.com')).toBe('/');
|
||||
expect(safeReturnTo('/\\example.com')).toBe('/');
|
||||
expect(safeReturnTo('javascript:alert(1)')).toBe('/');
|
||||
expect(safeReturnTo('relative/path')).toBe('/');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,13 @@ export interface Session extends SessionUser {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export function safeReturnTo(value: string | null | undefined): string {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
|
||||
return '/';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
let configPromise: Promise<client.Configuration> | undefined;
|
||||
export function getOidcConfig(): Promise<client.Configuration> {
|
||||
configPromise ??= (async () => {
|
||||
@@ -29,9 +36,9 @@ export function getOidcConfig(): Promise<client.Configuration> {
|
||||
return secret
|
||||
? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
|
||||
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
|
||||
})().catch((exception) => {
|
||||
})().catch((err) => {
|
||||
configPromise = undefined;
|
||||
throw exception;
|
||||
throw err;
|
||||
});
|
||||
return configPromise;
|
||||
}
|
||||
@@ -44,7 +51,7 @@ function sessionKey(): Promise<Uint8Array> {
|
||||
return keyPromise;
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(url: URL) {
|
||||
function sessionCookieOptions(url: URL) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
@@ -144,8 +151,8 @@ export async function refreshSession(session: Session): Promise<Session | null>
|
||||
idToken: tokens.id_token ?? session.idToken,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
|
||||
};
|
||||
} catch (exception) {
|
||||
console.error('Token refresh failed', exception);
|
||||
} catch (err) {
|
||||
console.error('Token refresh failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const DAY_SECONDS = 60 * 60 * 24;
|
||||
const MIN_SESSION_SECRET_LENGTH = 32;
|
||||
const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access';
|
||||
|
||||
function positiveInt(value: string | undefined, fallback: number): number {
|
||||
@@ -35,7 +36,13 @@ export const authConfig = {
|
||||
return required('AUTH_POST_LOGOUT_URI', env.AUTH_POST_LOGOUT_URI);
|
||||
},
|
||||
get sessionSecret() {
|
||||
return env.SESSION_SECRET;
|
||||
const secret = required('SESSION_SECRET', env.SESSION_SECRET);
|
||||
if (secret.length < MIN_SESSION_SECRET_LENGTH) {
|
||||
throw new Error(
|
||||
`SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long.`
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
},
|
||||
get sessionTtlSeconds() {
|
||||
return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS);
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import { fail, type RequestEvent } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { authedAction } from '$lib/server/api';
|
||||
|
||||
async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
const setFollowing = (follow: boolean) =>
|
||||
authedAction(async ({ api, params }) => {
|
||||
const id = params.id ?? '';
|
||||
if (!id) return fail(400, { message: 'Falta el usuario.' });
|
||||
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('userId') ?? '') || String(params.id ?? '');
|
||||
if (!id) return fail(400, { message: 'Falta el usuario.' });
|
||||
const options = { params: { path: { id } } };
|
||||
const { error } = follow
|
||||
? await api.POST('/users/{id}/follow', options)
|
||||
: await api.DELETE('/users/{id}/follow', options);
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const options = { params: { path: { id } } };
|
||||
const { error } = follow
|
||||
? await api.POST('/users/{id}/follow', options)
|
||||
: await api.DELETE('/users/{id}/follow', options);
|
||||
if (error) {
|
||||
return fail(502, {
|
||||
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
|
||||
});
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return fail(502, {
|
||||
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
|
||||
});
|
||||
}
|
||||
return { following: follow, userId: id };
|
||||
});
|
||||
|
||||
return { following: follow, userId: id };
|
||||
}
|
||||
|
||||
export const followUserAction = (event: RequestEvent) => setFollowing(event, true);
|
||||
export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false);
|
||||
export const followUserAction = setFollowing(true);
|
||||
export const unfollowUserAction = setFollowing(false);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { createApiClient } from '$lib/server/api';
|
||||
import type { ApiClient } from '$lib/server/api';
|
||||
import type { FollowList } from '$lib/types';
|
||||
|
||||
type ApiClient = ReturnType<typeof createApiClient>;
|
||||
|
||||
export function fetchFollowList(
|
||||
api: ApiClient,
|
||||
list: FollowList,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ALBUM_EARLIEST_YEAR } from '$lib/validation';
|
||||
import { parseAlbumForm } from './albumForm';
|
||||
|
||||
function form(fields: Record<string, string>): FormData {
|
||||
const data = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) data.set(key, value);
|
||||
return data;
|
||||
}
|
||||
|
||||
describe('parseAlbumForm', () => {
|
||||
it('parses a valid form and trims values', () => {
|
||||
expect(
|
||||
parseAlbumForm(form({ title: ' Title ', description: ' d ', releaseYear: '2020' }))
|
||||
).toEqual({ body: { title: 'Title', description: 'd', releaseYear: 2020 } });
|
||||
});
|
||||
it('turns empty optional fields into null', () => {
|
||||
expect(parseAlbumForm(form({ title: 'T' }))).toEqual({
|
||||
body: { title: 'T', description: null, releaseYear: null }
|
||||
});
|
||||
});
|
||||
it('rejects empty or too long titles', () => {
|
||||
expect(parseAlbumForm(form({ title: ' ' }))).toHaveProperty('failMessage');
|
||||
expect(parseAlbumForm(form({ title: 'x'.repeat(201) }))).toHaveProperty('failMessage');
|
||||
});
|
||||
it('rejects too long descriptions', () => {
|
||||
expect(parseAlbumForm(form({ title: 'T', description: 'x'.repeat(257) }))).toHaveProperty(
|
||||
'failMessage'
|
||||
);
|
||||
});
|
||||
it('rejects invalid years', () => {
|
||||
for (const releaseYear of ['abc', '2020.5', String(ALBUM_EARLIEST_YEAR - 1), '9999']) {
|
||||
expect(parseAlbumForm(form({ title: 'T', releaseYear }))).toHaveProperty('failMessage');
|
||||
}
|
||||
});
|
||||
});
|
||||
+11
-8
@@ -1,4 +1,5 @@
|
||||
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
||||
import { formString } from '$lib/server/api';
|
||||
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
||||
|
||||
interface AlbumFormBody {
|
||||
title: string;
|
||||
@@ -7,17 +8,19 @@ interface AlbumFormBody {
|
||||
}
|
||||
|
||||
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
|
||||
const title = String(form.get('title') ?? '').trim();
|
||||
if (title === '' || title.length > 200) {
|
||||
return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' };
|
||||
const title = formString(form, 'title').trim();
|
||||
if (title === '' || title.length > LIMITS.albumTitle) {
|
||||
return { failMessage: `El título es obligatorio (máx. ${LIMITS.albumTitle} caracteres).` };
|
||||
}
|
||||
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
if (description.length > 256) {
|
||||
return { failMessage: 'La descripción no puede pasar de 256 caracteres.' };
|
||||
const description = formString(form, 'description').trim();
|
||||
if (description.length > LIMITS.albumDescription) {
|
||||
return {
|
||||
failMessage: `La descripción no puede pasar de ${LIMITS.albumDescription} caracteres.`
|
||||
};
|
||||
}
|
||||
|
||||
const rawYear = String(form.get('releaseYear') ?? '').trim();
|
||||
const rawYear = formString(form, 'releaseYear').trim();
|
||||
const latestYear = new Date().getFullYear() + 1;
|
||||
let releaseYear: number | null = null;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
import { parsePlaylistForm } from './playlistForm';
|
||||
|
||||
function form(fields: Record<string, string>): FormData {
|
||||
const data = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) data.set(key, value);
|
||||
return data;
|
||||
}
|
||||
|
||||
describe('parsePlaylistForm', () => {
|
||||
it('parses a valid form', () => {
|
||||
expect(
|
||||
parsePlaylistForm(form({ name: ' Mix ', description: ' d ', visibility: 'public' }))
|
||||
).toEqual({ body: { name: 'Mix', description: 'd', visibility: 'Public', cover: null } });
|
||||
});
|
||||
it('defaults to private', () => {
|
||||
const parsed = parsePlaylistForm(form({ name: 'Mix' }));
|
||||
expect(parsed).toMatchObject({ body: { visibility: 'Private', description: '' } });
|
||||
});
|
||||
it('rejects empty or too long names keeping the typed values', () => {
|
||||
expect(parsePlaylistForm(form({ name: ' ', description: 'd' }))).toMatchObject({
|
||||
failMessage: expect.any(String),
|
||||
description: 'd'
|
||||
});
|
||||
expect(parsePlaylistForm(form({ name: 'x'.repeat(LIMITS.playlistName + 1) }))).toHaveProperty(
|
||||
'failMessage'
|
||||
);
|
||||
});
|
||||
it('rejects too long descriptions', () => {
|
||||
const description = 'x'.repeat(LIMITS.playlistDescription + 1);
|
||||
expect(parsePlaylistForm(form({ name: 'Mix', description }))).toHaveProperty('failMessage');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { formFile, formString } from '$lib/server/api';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
|
||||
interface PlaylistFormBody {
|
||||
name: string;
|
||||
description: string;
|
||||
visibility: 'Public' | 'Private';
|
||||
cover: File | null;
|
||||
}
|
||||
|
||||
export type PlaylistFormFailure = { failMessage: string; name: string; description: string };
|
||||
|
||||
export function parsePlaylistForm(
|
||||
form: FormData
|
||||
): { body: PlaylistFormBody } | PlaylistFormFailure {
|
||||
const name = formString(form, 'name').trim();
|
||||
const description = formString(form, 'description').trim();
|
||||
|
||||
if (name === '' || name.length > LIMITS.playlistName) {
|
||||
return {
|
||||
failMessage: `El nombre es obligatorio (máx. ${LIMITS.playlistName} caracteres).`,
|
||||
name,
|
||||
description
|
||||
};
|
||||
}
|
||||
if (description.length > LIMITS.playlistDescription) {
|
||||
return {
|
||||
failMessage: `La descripción no puede pasar de ${LIMITS.playlistDescription} caracteres.`,
|
||||
name,
|
||||
description
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
body: {
|
||||
name,
|
||||
description,
|
||||
visibility: formString(form, 'visibility') === 'public' ? 'Public' : 'Private',
|
||||
cover: formFile(form, 'cover')
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
import { parseTrackUploadForm } from './trackUploadForm';
|
||||
|
||||
function form(fields: Record<string, string | string[] | File>): FormData {
|
||||
const data = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (Array.isArray(value)) value.forEach((item) => data.append(key, item));
|
||||
else data.set(key, value);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const audio = new File(['a'], 'a.mp3');
|
||||
const cover = new File(['c'], 'c.png');
|
||||
|
||||
describe('parseTrackUploadForm', () => {
|
||||
it('parses a valid form and deduplicates tags', () => {
|
||||
const parsed = parseTrackUploadForm(
|
||||
form({ title: ' Song ', audio, cover, tags: ['rock', 'rock', 'pop'], isExplicit: 'on' })
|
||||
);
|
||||
expect(parsed).toMatchObject({
|
||||
body: { title: 'Song', tags: ['rock', 'pop'], isExplicit: true }
|
||||
});
|
||||
});
|
||||
it('validates each field', () => {
|
||||
const ok = { title: 'Song', audio, cover, tags: ['rock'] };
|
||||
expect(parseTrackUploadForm(form({ ...ok, title: '' }))).toHaveProperty('failMessage');
|
||||
expect(
|
||||
parseTrackUploadForm(form({ ...ok, title: 'x'.repeat(LIMITS.trackTitle + 1) }))
|
||||
).toHaveProperty('failMessage');
|
||||
expect(parseTrackUploadForm(form({ ...ok, audio: '' }))).toHaveProperty('failMessage');
|
||||
expect(parseTrackUploadForm(form({ ...ok, cover: '' }))).toHaveProperty('failMessage');
|
||||
expect(parseTrackUploadForm(form({ ...ok, tags: [] }))).toHaveProperty('failMessage');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { formFile, formString } from '$lib/server/api';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
|
||||
interface TrackUploadBody {
|
||||
title: string;
|
||||
audio: File;
|
||||
cover: File;
|
||||
tags: string[];
|
||||
isExplicit: boolean;
|
||||
}
|
||||
|
||||
export function parseTrackUploadForm(
|
||||
form: FormData
|
||||
): { body: TrackUploadBody } | { failMessage: string } {
|
||||
const title = formString(form, 'title').trim();
|
||||
if (title === '' || title.length > LIMITS.trackTitle) {
|
||||
return { failMessage: `El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).` };
|
||||
}
|
||||
|
||||
const audio = formFile(form, 'audio');
|
||||
if (!audio) return { failMessage: 'Selecciona un archivo de audio.' };
|
||||
|
||||
const cover = formFile(form, 'cover');
|
||||
if (!cover) return { failMessage: 'Selecciona una portada.' };
|
||||
|
||||
const tags = [...new Set(form.getAll('tags').map(String))];
|
||||
if (tags.length === 0) return { failMessage: 'Elige al menos un género.' };
|
||||
|
||||
return { body: { title, audio, cover, tags, isExplicit: form.get('isExplicit') === 'on' } };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pickGreeting } from './greeting';
|
||||
|
||||
const BIRTHDAY = 'Hoy toca celebrar. Tú eliges la música.';
|
||||
|
||||
describe('pickGreeting', () => {
|
||||
it('uses the birthday message over special dates', () => {
|
||||
const date = new Date(2026, 11, 25, 10);
|
||||
expect(pickGreeting(date, { month: 12, day: 25 })).toBe(BIRTHDAY);
|
||||
});
|
||||
it('uses the special date message', () => {
|
||||
expect(pickGreeting(new Date(2026, 11, 25, 10))).toBe('Feliz Navidad. Pon tu banda sonora.');
|
||||
});
|
||||
it('returns a non-empty schedule message at every hour', () => {
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
expect(pickGreeting(new Date(2026, 2, 10, hour)).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
it('ignores a birthday on another day', () => {
|
||||
expect(pickGreeting(new Date(2026, 2, 10, 10), { month: 1, day: 2 })).not.toBe(BIRTHDAY);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,9 @@ export async function proxyCoverImage(
|
||||
): Promise<Response> {
|
||||
const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium';
|
||||
|
||||
const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${id}/cover?size=${safeSize}`);
|
||||
const upstream = await fetch(
|
||||
`${apiConfig.baseUrl}/${kind}/${encodeURIComponent(id)}/cover?size=${safeSize}`
|
||||
);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return new Response(null, { status: upstream.status });
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { components } from '$lib/api/schema';
|
||||
import {
|
||||
toAlbum,
|
||||
toDatedTrack,
|
||||
toLikedTrack,
|
||||
toMix,
|
||||
toPage,
|
||||
toPlaylist,
|
||||
toPlaylistSummary,
|
||||
toTrack
|
||||
} from './mappers';
|
||||
|
||||
const trackDto: components['schemas']['TrackApplicationResponse'] = {
|
||||
id: 't1',
|
||||
title: 'Song',
|
||||
artist: null,
|
||||
audioStatus: 'Completed',
|
||||
duration: '183.5',
|
||||
listensCount: '12',
|
||||
createdAt: '2026-01-02T00:00:00Z',
|
||||
updatedAt: '2026-01-03T00:00:00Z',
|
||||
ownerUserId: '42',
|
||||
tags: [],
|
||||
isExplicit: true
|
||||
};
|
||||
|
||||
describe('toTrack', () => {
|
||||
it('normalizes numeric strings and renames isExplicit', () => {
|
||||
expect(toTrack(trackDto)).toEqual({
|
||||
id: 't1',
|
||||
title: 'Song',
|
||||
artist: null,
|
||||
ownerUserId: '42',
|
||||
explicit: true,
|
||||
duration: 183.5,
|
||||
listensCount: 12
|
||||
});
|
||||
});
|
||||
it('falls back to 0 for invalid numbers', () => {
|
||||
const track = toTrack({ ...trackDto, duration: 'x', listensCount: 'y' });
|
||||
expect(track.duration).toBe(0);
|
||||
expect(track.listensCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDatedTrack and toLikedTrack', () => {
|
||||
it('keep the creation date', () => {
|
||||
expect(toDatedTrack(trackDto).date).toBe('2026-01-02T00:00:00Z');
|
||||
expect(toLikedTrack(trackDto).likedAt).toBe(Date.parse('2026-01-02T00:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMix', () => {
|
||||
it('maps items into tracks', () => {
|
||||
const mix = toMix({
|
||||
id: 'm1',
|
||||
title: 'Mix',
|
||||
subtitle: null,
|
||||
itemCount: 1,
|
||||
items: [{ trackId: 't1', title: 'Song', artist: 'A', durationSeconds: '60', listensCount: 3 }]
|
||||
});
|
||||
expect(mix.tracks).toEqual([
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Song',
|
||||
artist: 'A',
|
||||
ownerUserId: null,
|
||||
explicit: false,
|
||||
duration: 60,
|
||||
listensCount: 3
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAlbum', () => {
|
||||
const dto: components['schemas']['AlbumApplicationResponse'] = {
|
||||
id: 'a1',
|
||||
title: 'Album',
|
||||
description: null,
|
||||
releaseYear: '2020',
|
||||
ownerUserId: '42',
|
||||
trackCount: '5',
|
||||
smallImageKeyName: null,
|
||||
mediumImageKeyName: null,
|
||||
largeImageKeyName: null,
|
||||
createdAt: '',
|
||||
updatedAt: 'u',
|
||||
coverTrackIds: ['t1']
|
||||
};
|
||||
it('normalizes year and count', () => {
|
||||
expect(toAlbum(dto)).toMatchObject({ releaseYear: 2020, trackCount: 5, updatedAt: 'u' });
|
||||
});
|
||||
it('keeps a missing year as null', () => {
|
||||
expect(toAlbum({ ...dto, releaseYear: null }).releaseYear).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPlaylist', () => {
|
||||
const dto: components['schemas']['PlayListApplicationResponse'] = {
|
||||
id: 'p1',
|
||||
name: 'List',
|
||||
description: 'd',
|
||||
smallImageKeyName: null,
|
||||
mediumImageKeyName: null,
|
||||
largeImageKeyName: null,
|
||||
ownerUserId: '42',
|
||||
visibility: 'Public',
|
||||
createdAt: '',
|
||||
updatedAt: 'u',
|
||||
coverTrackIds: []
|
||||
};
|
||||
it('drops storage keys', () => {
|
||||
expect(toPlaylist(dto)).toEqual({
|
||||
id: 'p1',
|
||||
name: 'List',
|
||||
description: 'd',
|
||||
visibility: 'Public',
|
||||
ownerUserId: '42',
|
||||
coverTrackIds: [],
|
||||
updatedAt: 'u'
|
||||
});
|
||||
});
|
||||
it('adds the track count in summaries', () => {
|
||||
expect(toPlaylistSummary(dto, 7).trackCount).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPage', () => {
|
||||
it('maps items and normalizes numbers', () => {
|
||||
expect(
|
||||
toPage(
|
||||
{ items: [1, 2], pageNumber: '2', hasNextPage: true, totalItemCount: '10' },
|
||||
(n) => n * 2
|
||||
)
|
||||
).toEqual({ items: [2, 4], pageNumber: 2, hasNextPage: true, totalItemCount: 10 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { components } from '$lib/api/schema';
|
||||
import type { Album, LikedTrack, Mix, Paged, Playlist, PlaylistSummary, Track } from '$lib/types';
|
||||
|
||||
type TrackDto = components['schemas']['TrackApplicationResponse'];
|
||||
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
|
||||
type PlaylistDto = components['schemas']['PlayListApplicationResponse'];
|
||||
type MixDto = components['schemas']['MixApplicationResponse'];
|
||||
type MixItemDto = components['schemas']['MixItemApplicationResponse'];
|
||||
|
||||
interface PageDto<T> {
|
||||
items: T[];
|
||||
pageNumber: number | string;
|
||||
hasNextPage: boolean;
|
||||
totalItemCount: number | string;
|
||||
}
|
||||
|
||||
export function toTrack(dto: TrackDto): Track {
|
||||
return {
|
||||
id: dto.id,
|
||||
title: dto.title,
|
||||
artist: dto.artist,
|
||||
ownerUserId: dto.ownerUserId,
|
||||
explicit: dto.isExplicit,
|
||||
duration: Number(dto.duration) || 0,
|
||||
listensCount: Number(dto.listensCount) || 0
|
||||
};
|
||||
}
|
||||
|
||||
export function toDatedTrack(dto: TrackDto): Track & { date: string } {
|
||||
return { ...toTrack(dto), date: dto.createdAt };
|
||||
}
|
||||
|
||||
export function toLikedTrack(dto: TrackDto): LikedTrack {
|
||||
return { ...toTrack(dto), likedAt: new Date(dto.createdAt).getTime() };
|
||||
}
|
||||
|
||||
export function toMixTrack(item: MixItemDto): Track {
|
||||
return {
|
||||
id: item.trackId,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
ownerUserId: null,
|
||||
explicit: false,
|
||||
duration: Number(item.durationSeconds) || 0,
|
||||
listensCount: Number(item.listensCount) || 0
|
||||
};
|
||||
}
|
||||
|
||||
export function toMix(dto: MixDto): Mix {
|
||||
return {
|
||||
id: dto.id,
|
||||
title: dto.title,
|
||||
subtitle: dto.subtitle,
|
||||
tracks: dto.items.map(toMixTrack)
|
||||
};
|
||||
}
|
||||
|
||||
export function toAlbum(dto: AlbumDto): Album {
|
||||
return {
|
||||
id: dto.id,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
releaseYear: dto.releaseYear === null ? null : Number(dto.releaseYear),
|
||||
ownerUserId: dto.ownerUserId,
|
||||
trackCount: Number(dto.trackCount) || 0,
|
||||
coverTrackIds: dto.coverTrackIds,
|
||||
updatedAt: dto.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
export function toPlaylist(dto: PlaylistDto): Playlist {
|
||||
return {
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
visibility: dto.visibility,
|
||||
ownerUserId: dto.ownerUserId,
|
||||
coverTrackIds: dto.coverTrackIds,
|
||||
updatedAt: dto.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
export function toPlaylistSummary(dto: PlaylistDto, trackCount: number): PlaylistSummary {
|
||||
return { ...toPlaylist(dto), trackCount };
|
||||
}
|
||||
|
||||
export function toPage<D, T>(dto: PageDto<D>, map: (item: D) => T): Paged<T> {
|
||||
return {
|
||||
items: dto.items.map(map),
|
||||
pageNumber: Number(dto.pageNumber) || 1,
|
||||
hasNextPage: dto.hasNextPage,
|
||||
totalItemCount: Number(dto.totalItemCount) || 0
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,8 @@ export async function getAllowAnonymousListening(fetchFn: typeof fetch): Promise
|
||||
|
||||
try {
|
||||
const api = createApiClient({ fetch: fetchFn });
|
||||
const { data } = await api.GET('/config/playback');
|
||||
const { data, error } = await api.GET('/config/playback');
|
||||
if (error !== undefined) return cached?.allowAnonymousListening ?? false;
|
||||
const allowAnonymousListening = data?.allowAnonymousListening ?? false;
|
||||
|
||||
cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { fail, type RequestEvent } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { authedAction, formString } from '$lib/server/api';
|
||||
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const form = await request.formData();
|
||||
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
|
||||
const trackId = String(form.get('trackId') ?? '');
|
||||
export const addTrackAction = authedAction(async ({ api, form, params }) => {
|
||||
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
|
||||
const trackId = formString(form, 'trackId');
|
||||
|
||||
if (!playlistId || !trackId) {
|
||||
return fail(400, { message: 'Faltan datos de la canción.' });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||
const { error, response } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId } },
|
||||
body: { trackId }
|
||||
});
|
||||
|
||||
if (err) {
|
||||
if (error) {
|
||||
if (response?.status === 409) {
|
||||
return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
|
||||
}
|
||||
@@ -28,37 +23,32 @@ export async function addTrackAction({ request, params, locals, fetch }: Request
|
||||
}
|
||||
|
||||
return { added: true, trackId, playlistId };
|
||||
}
|
||||
});
|
||||
|
||||
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const form = await request.formData();
|
||||
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
|
||||
const albumId = String(form.get('albumId') ?? '');
|
||||
export const addAlbumToPlaylistAction = authedAction(async ({ api, form, params }) => {
|
||||
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
|
||||
const albumId = formString(form, 'albumId');
|
||||
|
||||
if (!playlistId || !albumId) {
|
||||
return fail(400, { message: 'Faltan datos del álbum.' });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', {
|
||||
const { data: tracks, error: tracksError } = await api.GET('/albums/{albumId}/tracks', {
|
||||
params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } }
|
||||
});
|
||||
|
||||
if (tracksErr || !tracks) {
|
||||
if (tracksError || !tracks) {
|
||||
return fail(502, { message: 'No se pudo cargar el álbum.', albumId });
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
for (const track of tracks.items) {
|
||||
const { error: err } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||
const { error } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId } },
|
||||
body: { trackId: String(track.id) }
|
||||
body: { trackId: track.id }
|
||||
});
|
||||
if (!err) added++;
|
||||
if (!error) added++;
|
||||
}
|
||||
|
||||
return { addedAlbum: true, albumId, playlistId, added };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,6 +78,8 @@ export async function putPresigned(
|
||||
});
|
||||
}
|
||||
|
||||
import type { ApiClient } from '$lib/server/api';
|
||||
|
||||
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
|
||||
|
||||
export async function uploadPresignedImage(
|
||||
@@ -109,3 +111,18 @@ export async function uploadPresignedImage(
|
||||
|
||||
return { intentId: reserved.intentId };
|
||||
}
|
||||
|
||||
export async function uploadOptionalCover(
|
||||
api: ApiClient,
|
||||
endpoint: '/albums/upload-picture' | '/playlists/upload-picture',
|
||||
file: File | null
|
||||
): Promise<{ intentId: string | null } | { failMessage: string; detail?: string }> {
|
||||
if (!file) return { intentId: null };
|
||||
return uploadPresignedImage(
|
||||
(args) =>
|
||||
endpoint === '/albums/upload-picture'
|
||||
? api.POST('/albums/upload-picture', { body: args })
|
||||
: api.POST('/playlists/upload-picture', { body: args }),
|
||||
file
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import {
|
||||
contextMenuPosition,
|
||||
type MenuPosition
|
||||
} from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||
import { player, toQueueItem, type ApiTrackLike } from '../player/player.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { Track } from '$lib/types';
|
||||
|
||||
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition;
|
||||
export type TrackMenuState = { track: Track } & MenuPosition;
|
||||
|
||||
export function createTrackMenu() {
|
||||
let state = $state<TrackMenuState | null>(null);
|
||||
@@ -13,7 +14,7 @@ export function createTrackMenu() {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
open(event: MouseEvent, track: ApiTrackLike) {
|
||||
open(event: MouseEvent, track: Track) {
|
||||
state = { track, ...contextMenuPosition(event) };
|
||||
},
|
||||
close() {
|
||||
@@ -21,7 +22,7 @@ export function createTrackMenu() {
|
||||
},
|
||||
playNext() {
|
||||
if (!state) return;
|
||||
player.playNextItem(toQueueItem(state.track));
|
||||
player.playNext([state.track]);
|
||||
state = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { readStorage, writeStorage } from '$lib/utils/storage';
|
||||
|
||||
export type ThemeName = 'dark' | 'light';
|
||||
|
||||
@@ -6,11 +7,7 @@ const STORAGE_KEY = 'musify.theme';
|
||||
|
||||
function loadInitial(): ThemeName {
|
||||
if (!browser) return 'dark';
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark';
|
||||
} catch {
|
||||
return 'dark';
|
||||
}
|
||||
return readStorage(STORAGE_KEY) === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeName) {
|
||||
@@ -29,11 +26,7 @@ class ThemeModeStore {
|
||||
this.current = this.current === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(this.current);
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, this.current);
|
||||
} catch {
|
||||
/* localStorage unavailable (private mode, quota) — preference just won't persist */
|
||||
}
|
||||
writeStorage(STORAGE_KEY, this.current);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { ACCENT_LIGHTNESS } from './color';
|
||||
|
||||
export interface Accent {
|
||||
accent: string;
|
||||
gradient: string;
|
||||
}
|
||||
|
||||
function srgbToLinear(channel: number): number {
|
||||
const c = channel / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
@@ -45,7 +40,7 @@ function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
|
||||
const BUCKETS = 36;
|
||||
|
||||
export async function extractAccent(url: string): Promise<Accent | null> {
|
||||
export async function extractAccent(url: string): Promise<string | null> {
|
||||
if (!browser) return null;
|
||||
|
||||
let img: HTMLImageElement;
|
||||
@@ -104,11 +99,6 @@ export async function extractAccent(url: string): Promise<Accent | null> {
|
||||
|
||||
const h = hue.toFixed(1);
|
||||
const c = chroma.toFixed(3);
|
||||
const c2 = (chroma * 0.85).toFixed(3);
|
||||
const h2 = ((hue + 25) % 360).toFixed(1);
|
||||
|
||||
return {
|
||||
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
|
||||
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
|
||||
};
|
||||
return `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`;
|
||||
}
|
||||
|
||||
@@ -6,3 +6,56 @@ export interface SessionUser {
|
||||
}
|
||||
|
||||
export type FollowList = 'followers' | 'following';
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
ownerUserId: string | null;
|
||||
explicit: boolean;
|
||||
duration: number;
|
||||
listensCount: number;
|
||||
}
|
||||
|
||||
export interface LikedTrack extends Track {
|
||||
likedAt: number;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
releaseYear: number | null;
|
||||
ownerUserId: string;
|
||||
trackCount: number;
|
||||
coverTrackIds: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: 'Public' | 'Private';
|
||||
ownerUserId: string;
|
||||
coverTrackIds: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlaylistSummary extends Playlist {
|
||||
trackCount: number;
|
||||
}
|
||||
|
||||
export interface Mix {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
items: T[];
|
||||
pageNumber: number;
|
||||
hasNextPage: boolean;
|
||||
totalItemCount: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { albumMeta, fmtDurationLong, fmtTime, plural } from './format';
|
||||
|
||||
describe('fmtTime', () => {
|
||||
it('formats minutes and padded seconds', () => {
|
||||
expect(fmtTime(0)).toBe('0:00');
|
||||
expect(fmtTime(65)).toBe('1:05');
|
||||
expect(fmtTime(600)).toBe('10:00');
|
||||
});
|
||||
it('floors fractions and clamps invalid input', () => {
|
||||
expect(fmtTime(59.9)).toBe('0:59');
|
||||
expect(fmtTime(-5)).toBe('0:00');
|
||||
expect(fmtTime(NaN)).toBe('0:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtDurationLong', () => {
|
||||
it('shows minutes only under an hour', () => {
|
||||
expect(fmtDurationLong(59)).toBe('0 Min');
|
||||
expect(fmtDurationLong(125)).toBe('2 Min');
|
||||
});
|
||||
it('shows hours and minutes', () => {
|
||||
expect(fmtDurationLong(3600)).toBe('1 H 0 Min');
|
||||
expect(fmtDurationLong(3600 * 2 + 60 * 5)).toBe('2 H 5 Min');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plural', () => {
|
||||
it('uses the singular only for 1', () => {
|
||||
expect(plural(1, 'canción', 'canciones')).toBe('1 canción');
|
||||
expect(plural(0, 'canción', 'canciones')).toBe('0 canciones');
|
||||
expect(plural(2, 'canción', 'canciones')).toBe('2 canciones');
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumMeta', () => {
|
||||
it('joins year and track count', () => {
|
||||
expect(albumMeta(2020, 1)).toBe('2020 · 1 canción');
|
||||
expect(albumMeta(2020, 12)).toBe('2020 · 12 canciones');
|
||||
});
|
||||
it('omits missing parts', () => {
|
||||
expect(albumMeta(undefined, 3)).toBe('3 canciones');
|
||||
expect(albumMeta(2020)).toBe('2020');
|
||||
expect(albumMeta()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -22,12 +22,12 @@ export function plural(n: number, one: string, many: string): string {
|
||||
return `${n} ${n === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
export function fmtPlays(count: number | string | undefined): string {
|
||||
const value = Math.max(0, Math.floor(Number(count ?? 0)));
|
||||
export function fmtPlays(count: number): string {
|
||||
const value = Math.max(0, Math.floor(count));
|
||||
return plural(value, 'reproducción', 'reproducciones');
|
||||
}
|
||||
|
||||
export function albumMeta(releaseYear?: number, trackCount?: number): string {
|
||||
export function albumMeta(releaseYear?: number | null, trackCount?: number): string {
|
||||
return [
|
||||
releaseYear ? String(releaseYear) : undefined,
|
||||
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
|
||||
@@ -36,6 +36,12 @@ export function albumMeta(releaseYear?: number, trackCount?: number): string {
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '';
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
export function playlistMeta(trackCount: number): string {
|
||||
return plural(trackCount, 'canción', 'canciones');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function readStorage(key: string, fallback: string | null = null): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStorage(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* localStorage unavailable (private mode, quota) — the value just won't persist */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||
|
||||
export const LIMITS = {
|
||||
playlistName: 50,
|
||||
playlistDescription: 256,
|
||||
albumTitle: 200,
|
||||
albumDescription: 256,
|
||||
trackTitle: 200
|
||||
} as const;
|
||||
@@ -1,43 +1,31 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { toAlbum, toPage } from '$lib/server/mappers';
|
||||
import { ALBUMS_PAGE_SIZE } from '$lib/config';
|
||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
||||
import { parseAlbumForm } from '$lib/server/forms/albumForm';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
|
||||
const result = await api.GET('/albums/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
|
||||
});
|
||||
|
||||
const albums = unwrapOrError(result, 'No se pudieron cargar tus álbumes.');
|
||||
const albums = toPage(unwrapOrError(result, 'No se pudieron cargar tus álbumes.'), toAlbum);
|
||||
|
||||
return { albums };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const form = await request.formData();
|
||||
create: authedAction(async ({ api, form }) => {
|
||||
const parsed = parseAlbumForm(form);
|
||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||
|
||||
const cover = form.get('cover');
|
||||
if (!(cover instanceof File) || cover.size === 0) {
|
||||
return fail(400, { message: 'La portada es obligatoria.' });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const cover = formFile(form, 'cover');
|
||||
if (!cover) return fail(400, { message: 'La portada es obligatoria.' });
|
||||
|
||||
const uploaded = await uploadPresignedImage(
|
||||
(args) => api.POST('/albums/upload-picture', { body: args }),
|
||||
@@ -47,12 +35,12 @@ export const actions: Actions = {
|
||||
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
|
||||
}
|
||||
|
||||
const { data, error: err } = await api.POST('/albums', {
|
||||
const { data, error } = await api.POST('/albums', {
|
||||
body: { ...parsed.body, pictureIntentId: uploaded.intentId }
|
||||
});
|
||||
|
||||
if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
|
||||
if (error || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
|
||||
|
||||
redirect(303, `/albums/${data.id}`);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -40,10 +40,7 @@
|
||||
<MediaCard
|
||||
href="/albums/{album.id}"
|
||||
title={album.title}
|
||||
subtitle={albumMeta(
|
||||
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
||||
Number(album.trackCount)
|
||||
)}
|
||||
subtitle={albumMeta(album.releaseYear, album.trackCount)}
|
||||
trackIds={album.coverTrackIds}
|
||||
src="/api/albums/{album.id}/cover?size=large"
|
||||
index={i}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
apiFor,
|
||||
authedAction,
|
||||
failOnError,
|
||||
formFile,
|
||||
formString,
|
||||
optionalUser,
|
||||
requireAccessTokenAction,
|
||||
unwrapOrError,
|
||||
unwrapOrFail
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { toAlbum, toTrack } from '$lib/server/mappers';
|
||||
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
import { parseAlbumForm } from '$lib/server/forms/albumForm';
|
||||
import { uploadOptionalCover } from '$lib/server/upload';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
|
||||
const [albumRes, tracksRes, libraryRes] = await Promise.all([
|
||||
api.GET('/albums/{id}', { params: { path: { id: params.id } } }),
|
||||
@@ -34,11 +37,13 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
||||
: Promise.resolve(null)
|
||||
]);
|
||||
|
||||
const album = unwrapOrError(albumRes, 'Álbum no encontrado.', 404);
|
||||
const album = toAlbum(unwrapOrError(albumRes, 'Álbum no encontrado.', 404));
|
||||
|
||||
const tracks = tracksRes.data?.items ?? [];
|
||||
const tracks = (tracksRes.data?.items ?? []).map(toTrack);
|
||||
const inAlbum = new Set(tracks.map((track) => track.id));
|
||||
const library = (libraryRes?.data?.items ?? []).filter((track) => !inAlbum.has(track.id));
|
||||
const library = (libraryRes?.data?.items ?? [])
|
||||
.filter((track) => !inAlbum.has(track.id))
|
||||
.map(toTrack);
|
||||
|
||||
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
|
||||
|
||||
@@ -46,59 +51,33 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||
addTrack: authedAction(async ({ api, form, params }) => {
|
||||
const trackId = formString(form, 'trackId');
|
||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
|
||||
params: { path: { albumId: params.id, trackId } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo añadir la canción.');
|
||||
if (failure) return failure;
|
||||
return { added: true };
|
||||
},
|
||||
return failOnError(result, 'No se pudo añadir la canción.') ?? { added: true };
|
||||
}),
|
||||
|
||||
removeTrack: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||
removeTrack: authedAction(async ({ api, form, params }) => {
|
||||
const trackId = formString(form, 'trackId');
|
||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
|
||||
params: { path: { albumId: params.id, trackId } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
|
||||
if (failure) return failure;
|
||||
return { removed: true };
|
||||
},
|
||||
return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
|
||||
}),
|
||||
|
||||
edit: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const form = await request.formData();
|
||||
edit: authedAction(async ({ api, form, params }) => {
|
||||
const parsed = parseAlbumForm(form);
|
||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
|
||||
let newPictureIntentId: string | null = null;
|
||||
const cover = form.get('cover');
|
||||
if (cover instanceof File && cover.size > 0) {
|
||||
const uploaded = await uploadPresignedImage(
|
||||
(args) => api.POST('/albums/upload-picture', { body: args }),
|
||||
cover
|
||||
);
|
||||
if ('failMessage' in uploaded) {
|
||||
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
|
||||
}
|
||||
newPictureIntentId = uploaded.intentId;
|
||||
const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover'));
|
||||
if ('failMessage' in cover) {
|
||||
return fail(502, { message: cover.failMessage, detail: cover.detail });
|
||||
}
|
||||
|
||||
const result = await api.PUT('/albums/{albumId}', {
|
||||
@@ -107,24 +86,18 @@ export const actions: Actions = {
|
||||
newTitle: parsed.body.title,
|
||||
newDescription: parsed.body.description,
|
||||
newReleaseYear: parsed.body.releaseYear,
|
||||
newPictureIntentId
|
||||
newPictureIntentId: cover.intentId
|
||||
}
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.');
|
||||
if (failure) return failure;
|
||||
return { edited: true };
|
||||
},
|
||||
return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true };
|
||||
}),
|
||||
|
||||
delete: async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
delete: authedAction(async ({ api, params }) => {
|
||||
const result = await api.DELETE('/albums/{albumId}', {
|
||||
params: { path: { albumId: params.id } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo eliminar el álbum.');
|
||||
const failure = failOnError(result, 'No se pudo eliminar el álbum.');
|
||||
if (failure) return failure;
|
||||
redirect(303, '/albums');
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
@@ -28,15 +28,15 @@
|
||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||
|
||||
function playAll() {
|
||||
playAllOrToggle(toQueueItems(tracks));
|
||||
playAllOrToggle(tracks);
|
||||
}
|
||||
|
||||
function playTrackFrom(index: number) {
|
||||
player.playOrToggle(toQueueItems(tracks), index);
|
||||
player.playOrToggle(tracks, index);
|
||||
}
|
||||
|
||||
function playLibraryFrom(index: number) {
|
||||
player.playOrToggle(toQueueItems(library), index);
|
||||
player.playOrToggle(library, index);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,10 +55,7 @@
|
||||
eyebrow="Álbum"
|
||||
title={album.title}
|
||||
description={album.description ?? undefined}
|
||||
meta={albumMeta(
|
||||
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
||||
tracks.length
|
||||
)}
|
||||
meta={albumMeta(album.releaseYear, tracks.length)}
|
||||
>
|
||||
{#snippet cover()}
|
||||
<Artwork
|
||||
@@ -118,7 +115,7 @@
|
||||
action="?/edit"
|
||||
initialTitle={album.title}
|
||||
initialDescription={album.description ?? ''}
|
||||
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
initialReleaseYear={album.releaseYear ?? undefined}
|
||||
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
|
||||
formMessage={form?.message}
|
||||
submitLabel="Guardar"
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
|
||||
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
|
||||
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
||||
import {
|
||||
EXPLORE_ALBUMS_PAGE_SIZE,
|
||||
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
||||
EXPLORE_USERS_LIMIT
|
||||
} from '$lib/config';
|
||||
import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
@@ -14,7 +10,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
||||
|
||||
const user = locals.user;
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
|
||||
const genresRes = await api.GET('/genres');
|
||||
const genres = genresRes.data ?? [];
|
||||
@@ -26,17 +22,11 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
name: query || undefined,
|
||||
genre: genre ?? undefined,
|
||||
pageNumber: page,
|
||||
pageSize: PAGE_SIZE
|
||||
pageSize: EXPLORE_PAGE_SIZE
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const playlistsPromise = user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const albumsPromise = query
|
||||
? api.GET('/albums', {
|
||||
params: { query: { title: query, pageNumber: 1, pageSize: EXPLORE_ALBUMS_PAGE_SIZE } }
|
||||
@@ -49,30 +39,30 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([
|
||||
const [tracksRes, albumsRes, usersRes] = await Promise.all([
|
||||
tracksPromise,
|
||||
playlistsPromise,
|
||||
albumsPromise,
|
||||
usersPromise
|
||||
]);
|
||||
|
||||
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
||||
const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) =>
|
||||
toTrack(item.track)
|
||||
);
|
||||
|
||||
return {
|
||||
query,
|
||||
genre,
|
||||
genres,
|
||||
tracks,
|
||||
albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
|
||||
albums: (albumsRes?.data?.items ?? []).map((item) => toAlbum(item.album)),
|
||||
albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0),
|
||||
users: usersRes?.data?.items ?? [],
|
||||
viewerId: user?.sub ?? null,
|
||||
playlists: playlistsRes?.data?.items ?? []
|
||||
usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
|
||||
viewerId: user?.sub ?? null
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addAlbumToPlaylist: addAlbumToPlaylistAction,
|
||||
follow: followUserAction,
|
||||
unfollow: unfollowUserAction
|
||||
addAlbumToPlaylist: addAlbumToPlaylistAction
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { player, toQueueItem } from '$lib/player/player.svelte';
|
||||
import { fetchAlbumQueueItems } from '$lib/data/albums';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fetchAlbumTracks } from '$lib/data/albums';
|
||||
import type { Track } from '$lib/types';
|
||||
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
@@ -49,9 +50,7 @@
|
||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||
let sfilter = $state<SearchFilter>('Todo');
|
||||
|
||||
type LocalTrack = (typeof data.tracks.items)[number]['track'];
|
||||
|
||||
let localItems = $state<LocalTrack[]>([]);
|
||||
let localItems = $state<Track[]>([]);
|
||||
let localPage = $state(1);
|
||||
let hasMore = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
@@ -60,19 +59,12 @@
|
||||
trackMenu.close();
|
||||
albumMenu = null;
|
||||
sfilter = data.genre ? 'Canciones' : 'Todo';
|
||||
localItems = data.tracks.items.map((item) => item.track);
|
||||
localPage = Number(data.tracks.pageNumber);
|
||||
localItems = data.tracks.items;
|
||||
localPage = data.tracks.pageNumber;
|
||||
hasMore = data.tracks.hasNextPage;
|
||||
});
|
||||
|
||||
const songRows = $derived(
|
||||
localItems.map((track) => ({
|
||||
track,
|
||||
seconds: Number(track.duration)
|
||||
}))
|
||||
);
|
||||
|
||||
const items = $derived(songRows.map((row) => row.track));
|
||||
const items = $derived(localItems);
|
||||
|
||||
const tiles = $derived(genreTiles(data.genres));
|
||||
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
|
||||
@@ -83,7 +75,7 @@
|
||||
const hasAlbums = $derived(albums.length > 0);
|
||||
const hasUsers = $derived(users.length > 0);
|
||||
|
||||
const playlistMatches = $derived(matchPlaylists(data.playlists, data.query));
|
||||
const playlistMatches = $derived(matchPlaylists(data.userPlaylists, data.query));
|
||||
const hasPlaylists = $derived(playlistMatches.length > 0);
|
||||
|
||||
const nothingFound = $derived(
|
||||
@@ -92,10 +84,10 @@
|
||||
|
||||
const counts = $derived(
|
||||
searchCounts({
|
||||
tracks: songRows.length,
|
||||
albums: albums.length,
|
||||
tracks: data.tracks.totalItemCount,
|
||||
albums: data.albumsTotal,
|
||||
playlists: playlistMatches.length,
|
||||
users: users.length
|
||||
users: data.usersTotal
|
||||
})
|
||||
);
|
||||
const chips = $derived(searchChips(counts));
|
||||
@@ -106,7 +98,7 @@
|
||||
type TopResult = NonNullable<
|
||||
ReturnType<
|
||||
typeof findTopResult<
|
||||
LocalTrack,
|
||||
Track,
|
||||
(typeof albums)[number],
|
||||
(typeof playlistMatches)[number],
|
||||
(typeof users)[number]
|
||||
@@ -128,7 +120,7 @@
|
||||
|
||||
function playTopResult() {
|
||||
if (topResult?.kind !== 'track') return;
|
||||
player.playOrToggle([toQueueItem(topResult.track)], 0);
|
||||
player.playOrToggle([topResult.track], 0);
|
||||
}
|
||||
|
||||
const topResultHref = $derived.by(() => {
|
||||
@@ -139,11 +131,7 @@
|
||||
});
|
||||
|
||||
function togglePlay(index: number) {
|
||||
player.playOrToggle(items.map(toQueueItem), index);
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, track: LocalTrack) {
|
||||
trackMenu.open(event, track);
|
||||
player.playOrToggle(items, index);
|
||||
}
|
||||
|
||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||
@@ -154,14 +142,14 @@
|
||||
if (!albumMenu) return;
|
||||
const { albumId } = albumMenu;
|
||||
albumMenu = null;
|
||||
player.playNext(await fetchAlbumQueueItems(albumId));
|
||||
player.playNext(await fetchAlbumTracks(albumId));
|
||||
}
|
||||
|
||||
async function albumAddToQueue() {
|
||||
if (!albumMenu) return;
|
||||
const { albumId } = albumMenu;
|
||||
albumMenu = null;
|
||||
player.appendToQueue(await fetchAlbumQueueItems(albumId));
|
||||
player.appendToQueue(await fetchAlbumTracks(albumId));
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
@@ -177,12 +165,8 @@
|
||||
const res = await fetch(`/api/tracks?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const next = (await res.json()) as typeof data.tracks;
|
||||
localItems = appendUnique(
|
||||
localItems,
|
||||
next.items.map((item) => item.track),
|
||||
(track) => track.id
|
||||
);
|
||||
localPage = Number(next.pageNumber);
|
||||
localItems = appendUnique(localItems, next.items, (track) => track.id);
|
||||
localPage = next.pageNumber;
|
||||
hasMore = next.hasNextPage;
|
||||
} catch {
|
||||
hasMore = false;
|
||||
@@ -340,30 +324,30 @@
|
||||
<div class="mt-9 flex flex-col gap-9">
|
||||
{#if showGroup(sfilter, 'Canciones')}
|
||||
<div>
|
||||
{#if songRows.length > 0}
|
||||
{#if items.length > 0}
|
||||
<SectionHeading title="Canciones" count={counts.Canciones} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(sfilter, songRows) as { track, seconds }, i (track.id)}
|
||||
{#each capped(sfilter, items) as track, i (track.id)}
|
||||
<li>
|
||||
<ListRow
|
||||
title={track.title}
|
||||
subtitle={track.artist}
|
||||
subtitleHref={track.ownerUserId}
|
||||
explicit={track.isExplicit}
|
||||
active={player.current.id === track.id}
|
||||
explicit={track.explicit}
|
||||
active={player.currentId === track.id}
|
||||
size="lg"
|
||||
trackId={track.id}
|
||||
onclick={() => togglePlay(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, track)}
|
||||
oncontextmenu={(e) => trackMenu.open(e, track)}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
{#if player.current.id === track.id}
|
||||
{#if player.currentId === track.id}
|
||||
<EqBars overlay paused={!player.playing} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
||||
{fmtTime(seconds)} · {fmtPlays(track.listensCount)}
|
||||
{fmtTime(track.duration)} · {fmtPlays(track.listensCount)}
|
||||
</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
@@ -400,7 +384,7 @@
|
||||
>
|
||||
{#snippet trailing()}
|
||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
||||
{Number(album.trackCount)} canciones
|
||||
{plural(album.trackCount, 'canción', 'canciones')}
|
||||
</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
@@ -459,10 +443,10 @@
|
||||
]}
|
||||
playlistAction={{
|
||||
action: '?/addTrack',
|
||||
fields: { trackId: String(trackMenu.state.track.id) },
|
||||
fields: { trackId: trackMenu.state.track.id },
|
||||
label: 'Añadir a una playlist'
|
||||
}}
|
||||
playlists={data.playlists}
|
||||
playlists={data.userPlaylists}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -481,6 +465,6 @@
|
||||
fields: { albumId: albumMenu.albumId },
|
||||
label: 'Añadir álbum a una playlist'
|
||||
}}
|
||||
playlists={data.playlists}
|
||||
playlists={data.userPlaylists}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { toDatedTrack, toPage } from '$lib/server/mappers';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
apiFor,
|
||||
authedAction,
|
||||
failOnError,
|
||||
formString,
|
||||
requireUser,
|
||||
unwrapOrError,
|
||||
unwrapOrFail
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
import { LIBRARY_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
const result = await api.GET('/tracks/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
|
||||
});
|
||||
|
||||
return { tracks: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') };
|
||||
return {
|
||||
tracks: toPage(unwrapOrError(result, 'No se pudo cargar tu biblioteca.'), toDatedTrack)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
deleteTrack: async ({ request, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||
deleteTrack: authedAction(async ({ api, form }) => {
|
||||
const trackId = formString(form, 'trackId');
|
||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.DELETE('/tracks/{trackId}', {
|
||||
params: { path: { trackId } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.');
|
||||
if (failure) return failure;
|
||||
return { deleted: true };
|
||||
}
|
||||
return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true };
|
||||
})
|
||||
};
|
||||
|
||||
@@ -2,22 +2,24 @@
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
|
||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const items = $derived(data.tracks.items);
|
||||
const total = $derived(Number(data.tracks.totalItemCount));
|
||||
const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt })));
|
||||
const total = $derived(data.tracks.totalItemCount);
|
||||
|
||||
let deleting = $state<(typeof items)[number] | null>(null);
|
||||
|
||||
function togglePlay(index: number) {
|
||||
player.playOrToggle(toQueueItems(items), index);
|
||||
player.playOrToggle(items, index);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -43,10 +45,14 @@
|
||||
|
||||
{#if items.length > 0}
|
||||
<TrackList
|
||||
tracks={listTracks}
|
||||
tracks={items}
|
||||
columns={['uploaded', 'plays']}
|
||||
onPlay={togglePlay}
|
||||
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }}
|
||||
rowAction={{
|
||||
onclick: (track) => (deleting = items.find((item) => item.id === track.id) ?? null),
|
||||
icon: X,
|
||||
label: 'Borrar canción'
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
@@ -63,3 +69,13 @@
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onClose={() => (deleting = null)}
|
||||
title="¿Borrar «{deleting?.title ?? ''}»?"
|
||||
description="Esta acción no se puede deshacer, la canción se eliminará de tu biblioteca, de tus álbumes y de las playlists donde esté."
|
||||
confirmLabel="Borrar"
|
||||
action="?/deleteTrack"
|
||||
fields={{ trackId: deleting?.id ?? '' }}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||
|
||||
const tracks = $derived(liked.list);
|
||||
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.likedAt })));
|
||||
const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt })));
|
||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||
|
||||
function playFrom(index: number) {
|
||||
@@ -55,7 +55,7 @@
|
||||
columns={['added', 'plays']}
|
||||
onPlay={playFrom}
|
||||
rowAction={{
|
||||
onclick: (track) => liked.toggle({ ...track, artist: track.artist ?? undefined }),
|
||||
onclick: (track) => liked.toggle(track),
|
||||
icon: X,
|
||||
label: 'Quitar de Me gusta'
|
||||
}}
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { toMix } from '$lib/server/mappers';
|
||||
import { addTrackAction } from '$lib/server/playlistActions';
|
||||
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
requireUser(locals, url);
|
||||
const api = apiFor({ fetch, locals });
|
||||
|
||||
const [mixRes, playlistsRes] = await Promise.all([
|
||||
api.GET('/mixes/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/playlists/users/{userId}', {
|
||||
params: {
|
||||
path: { userId: user.sub },
|
||||
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
|
||||
}
|
||||
})
|
||||
]);
|
||||
const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } });
|
||||
|
||||
const mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404);
|
||||
|
||||
return { mix, playlists: playlistsRes.data?.items ?? [] };
|
||||
return { mix: toMix(unwrapOrError(mixRes, 'Mezcla no encontrada.', 404)) };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import {
|
||||
player,
|
||||
isQueueCurrent,
|
||||
playAllOrToggle,
|
||||
playShuffled,
|
||||
toQueueItem
|
||||
} from '$lib/player/player.svelte';
|
||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||
import { fmtTime, plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
||||
@@ -18,33 +12,19 @@
|
||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import { createTrackMenu } from '$lib/state/menus.svelte';
|
||||
import { mixItemTrack } from '$lib/data/mixes';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const mix = $derived(data.mix);
|
||||
const items = $derived(mix.items);
|
||||
const tracks = $derived(items.map(mixItemTrack));
|
||||
const queue = $derived(tracks.map(toQueueItem));
|
||||
const listTracks = $derived(
|
||||
items.map((item) => ({
|
||||
id: item.trackId,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
duration: item.durationSeconds,
|
||||
listensCount: item.listensCount
|
||||
}))
|
||||
);
|
||||
const tracks = $derived(mix.tracks);
|
||||
|
||||
const totalSeconds = $derived(
|
||||
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
|
||||
);
|
||||
const isCurrentQueue = $derived(isQueueCurrent(queue));
|
||||
const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0));
|
||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||
|
||||
const trackMenu = createTrackMenu();
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(queue, index);
|
||||
player.playOrToggle(tracks, index);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -60,18 +40,18 @@
|
||||
eyebrow="Mezcla"
|
||||
title={mix.title}
|
||||
description={mix.subtitle ?? undefined}
|
||||
meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
|
||||
meta="{plural(tracks.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
|
||||
>
|
||||
{#snippet cover()}
|
||||
<Artwork
|
||||
trackIds={items.map((item) => item.trackId)}
|
||||
trackIds={tracks.map((track) => track.id)}
|
||||
size="hero"
|
||||
alt={mix.title}
|
||||
class="shrink-0"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}>
|
||||
<Button size="sm" onclick={() => playAllOrToggle(tracks)} disabled={tracks.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
Pausar
|
||||
{:else}
|
||||
@@ -81,8 +61,8 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => playShuffled(queue)}
|
||||
disabled={queue.length === 0}
|
||||
onclick={() => playShuffled(tracks)}
|
||||
disabled={tracks.length === 0}
|
||||
>
|
||||
<Shuffle class="size-4" />
|
||||
Aleatorio
|
||||
@@ -95,7 +75,7 @@
|
||||
{/if}
|
||||
|
||||
<TrackList
|
||||
tracks={listTracks}
|
||||
{tracks}
|
||||
columns={['plays']}
|
||||
onPlay={playFrom}
|
||||
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
|
||||
@@ -113,9 +93,9 @@
|
||||
]}
|
||||
playlistAction={{
|
||||
action: '?/addTrack',
|
||||
fields: { trackId: String(trackMenu.state.track.id) },
|
||||
fields: { trackId: trackMenu.state.track.id },
|
||||
label: 'Añadir a una playlist'
|
||||
}}
|
||||
playlists={data.playlists}
|
||||
playlists={data.userPlaylists}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
|
||||
const MAX_NAME = 100;
|
||||
import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
|
||||
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
||||
import { uploadOptionalCover } from '$lib/server/upload';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
const result = await api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } }
|
||||
});
|
||||
|
||||
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
|
||||
@@ -23,68 +19,54 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
data.items.map((playlist) =>
|
||||
api
|
||||
.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
|
||||
params: {
|
||||
path: { playlistId: playlist.id },
|
||||
query: { pageNumber: 1, pageSize: PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE }
|
||||
}
|
||||
})
|
||||
.then((res) => res.data?.items ?? [])
|
||||
.then((res) => (res.data?.items ?? []).map(toTrack))
|
||||
)
|
||||
);
|
||||
|
||||
const items = data.items.map((playlist, i) => {
|
||||
const tracks = tracksByPlaylist[i];
|
||||
const trackCount = tracks.length;
|
||||
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
|
||||
return { ...playlist, trackCount, durationSeconds };
|
||||
});
|
||||
const items = data.items.map((playlist, i) =>
|
||||
toPlaylistSummary(playlist, tracksByPlaylist[i].length)
|
||||
);
|
||||
|
||||
const totals = {
|
||||
playlistCount: items.length,
|
||||
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
|
||||
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
|
||||
trackCount: items.reduce((sum, playlist) => sum + playlist.trackCount, 0),
|
||||
durationSeconds: tracksByPlaylist.flat().reduce((sum, track) => sum + track.duration, 0)
|
||||
};
|
||||
|
||||
return { playlists: { ...data, items }, totals };
|
||||
return { playlists: items, totals };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
create: authedAction(async ({ api, form }) => {
|
||||
const parsed = parsePlaylistForm(form);
|
||||
if ('failMessage' in parsed) {
|
||||
const { failMessage: message, name, description } = parsed;
|
||||
return fail(400, { message, name, description });
|
||||
}
|
||||
const { name, description, visibility, cover } = parsed.body;
|
||||
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
|
||||
const cover = form.get('cover');
|
||||
|
||||
if (name === '' || name.length > MAX_NAME) {
|
||||
return fail(400, {
|
||||
message: 'El nombre es obligatorio (máx. 100 caracteres).',
|
||||
const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
|
||||
if ('failMessage' in uploaded) {
|
||||
return fail(502, {
|
||||
message: uploaded.failMessage,
|
||||
name,
|
||||
description
|
||||
description,
|
||||
detail: uploaded.detail
|
||||
});
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
|
||||
let pictureIntentId: string | null = null;
|
||||
if (cover instanceof File && cover.size > 0) {
|
||||
const result = await uploadPresignedImage(
|
||||
(args) => api.POST('/playlists/upload-picture', { body: args }),
|
||||
cover
|
||||
);
|
||||
if ('failMessage' in result) {
|
||||
return fail(502, { message: result.failMessage, name, description, detail: result.detail });
|
||||
}
|
||||
pictureIntentId = result.intentId;
|
||||
}
|
||||
|
||||
const { data, error: err } = await api.POST('/playlists', {
|
||||
body: { name, description, pictureIntentId, visibility }
|
||||
const { data, error } = await api.POST('/playlists', {
|
||||
body: { name, description, pictureIntentId: uploaded.intentId, visibility }
|
||||
});
|
||||
|
||||
if (err || !data)
|
||||
if (error || !data)
|
||||
return fail(502, { message: 'No se pudo crear la playlist.', name, description });
|
||||
|
||||
redirect(303, `/playlists/${data.id}`);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const items = $derived(data.playlists.items);
|
||||
const items = $derived(data.playlists);
|
||||
const totals = $derived(data.totals);
|
||||
const summary = $derived(
|
||||
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
|
||||
@@ -40,7 +40,7 @@
|
||||
<MediaCard
|
||||
href="/playlists/{playlist.id}"
|
||||
title={playlist.name}
|
||||
subtitle={playlistMeta(Number(playlist.trackCount))}
|
||||
subtitle={playlistMeta(playlist.trackCount)}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
|
||||
@@ -1,95 +1,80 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
apiFor,
|
||||
authedAction,
|
||||
failOnError,
|
||||
formString,
|
||||
optionalUser,
|
||||
requireAccessTokenAction,
|
||||
unwrapOrError,
|
||||
unwrapOrFail
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
|
||||
const MAX_NAME = 100;
|
||||
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
|
||||
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
||||
import { uploadOptionalCover } from '$lib/server/upload';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = apiFor({ fetch, locals });
|
||||
|
||||
const [playlistRes, tracksRes] = await Promise.all([
|
||||
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
|
||||
params: {
|
||||
path: { playlistId: params.id },
|
||||
query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
|
||||
const tracks = tracksRes.data?.items ?? [];
|
||||
const playlist = toPlaylist(unwrapOrError(playlistRes, 'Playlist no encontrada.', 404));
|
||||
const tracks = (tracksRes.data?.items ?? []).map(toDatedTrack);
|
||||
|
||||
return { playlist, tracks };
|
||||
const isOwner = user !== null && String(playlist.ownerUserId) === user.sub;
|
||||
|
||||
return { playlist, tracks, isOwner, section: isOwner ? '/playlists' : null };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
removeTrack: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||
removeTrack: authedAction(async ({ api, form, params }) => {
|
||||
const trackId = formString(form, 'trackId');
|
||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
|
||||
params: { path: { playlistId: params.id, trackId } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
|
||||
if (failure) return failure;
|
||||
return { removed: true };
|
||||
},
|
||||
return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
|
||||
}),
|
||||
|
||||
rename: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
|
||||
const cover = form.get('cover');
|
||||
if (name === '' || name.length > MAX_NAME) {
|
||||
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
|
||||
}
|
||||
edit: authedAction(async ({ api, form, params }) => {
|
||||
const parsed = parsePlaylistForm(form);
|
||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||
const { name, description, visibility, cover } = parsed.body;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
|
||||
let newPictureIntentId: string | null = null;
|
||||
if (cover instanceof File && cover.size > 0) {
|
||||
const result = await uploadPresignedImage(
|
||||
(args) => api.POST('/playlists/upload-picture', { body: args }),
|
||||
cover
|
||||
);
|
||||
if ('failMessage' in result) {
|
||||
return fail(502, { message: result.failMessage, detail: result.detail });
|
||||
}
|
||||
newPictureIntentId = result.intentId;
|
||||
const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
|
||||
if ('failMessage' in uploaded) {
|
||||
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
|
||||
}
|
||||
|
||||
const result = await api.PUT('/playlists/{playlistId}', {
|
||||
params: { path: { playlistId: params.id } },
|
||||
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
|
||||
body: {
|
||||
newName: name,
|
||||
newDescription: description,
|
||||
newPictureIntentId: uploaded.intentId,
|
||||
newVisibility: visibility
|
||||
}
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
|
||||
if (failure) return failure;
|
||||
return { renamed: true };
|
||||
},
|
||||
return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true };
|
||||
}),
|
||||
|
||||
delete: async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
delete: authedAction(async ({ api, params }) => {
|
||||
const result = await api.DELETE('/playlists/{playlistId}', {
|
||||
params: { path: { playlistId: params.id } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.');
|
||||
const failure = failOnError(result, 'No se pudo eliminar la playlist.');
|
||||
if (failure) return failure;
|
||||
redirect(303, '/playlists');
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
||||
@@ -9,6 +9,7 @@
|
||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||
import { playlistMeta } from '$lib/utils/format';
|
||||
@@ -17,7 +18,7 @@
|
||||
|
||||
const playlist = $derived(data.playlist);
|
||||
const tracks = $derived(data.tracks);
|
||||
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt })));
|
||||
const isOwner = $derived(data.isOwner);
|
||||
|
||||
let editing = $state(false);
|
||||
let confirmingDelete = $state(false);
|
||||
@@ -25,11 +26,11 @@
|
||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||
|
||||
function playAll() {
|
||||
playAllOrToggle(toQueueItems(tracks));
|
||||
playAllOrToggle(tracks);
|
||||
}
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(toQueueItems(tracks), index);
|
||||
player.playOrToggle(tracks, index);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,8 +40,13 @@
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<BackLink
|
||||
href={isOwner ? '/playlists' : '/'}
|
||||
label={isOwner ? 'Volver a tus playlists' : 'Volver al inicio'}
|
||||
/>
|
||||
|
||||
<PageHeader
|
||||
eyebrow="Lista"
|
||||
eyebrow="Playlist"
|
||||
title={playlist.name}
|
||||
description={playlist.description ?? undefined}
|
||||
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
|
||||
@@ -67,19 +73,23 @@
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
|
||||
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
||||
Eliminar
|
||||
</Button>
|
||||
{#if isOwner}
|
||||
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
|
||||
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
||||
Eliminar
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if tracks.length > 0}
|
||||
<TrackList
|
||||
tracks={listTracks}
|
||||
{tracks}
|
||||
columns={['added', 'plays']}
|
||||
onPlay={playFrom}
|
||||
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}
|
||||
rowAction={isOwner
|
||||
? { action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }
|
||||
: undefined}
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
@@ -96,7 +106,7 @@
|
||||
|
||||
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
|
||||
<PlaylistForm
|
||||
action="?/rename"
|
||||
action="?/edit"
|
||||
initialName={playlist.name}
|
||||
initialDescription={playlist.description ?? ''}
|
||||
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
|
||||
|
||||
@@ -1,59 +1,33 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api';
|
||||
import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm';
|
||||
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
||||
import { findConflict, genreInfo } from '$lib/data/genres';
|
||||
import type { components } from '$lib/api/schema';
|
||||
|
||||
type Genre = components['schemas']['Genre'];
|
||||
|
||||
const MAX_TITLE = 100;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
const { data } = await api.GET('/genres/available');
|
||||
return { genres: data ?? [] };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.');
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
default: authedAction(async ({ api, form }) => {
|
||||
const parsed = parseTrackUploadForm(form);
|
||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||
const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body;
|
||||
|
||||
const form = await request.formData();
|
||||
const title = String(form.get('title') ?? '').trim();
|
||||
const audio = form.get('audio');
|
||||
const cover = form.get('cover');
|
||||
const rawTags = form.getAll('tags').map(String);
|
||||
const isExplicit = form.get('isExplicit') === 'on';
|
||||
|
||||
if (title === '' || title.length > MAX_TITLE) {
|
||||
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
|
||||
}
|
||||
if (!(audio instanceof File) || audio.size === 0) {
|
||||
return fail(400, { message: 'Selecciona un archivo de audio.' });
|
||||
}
|
||||
if (!(cover instanceof File) || cover.size === 0) {
|
||||
return fail(400, { message: 'Selecciona una portada.' });
|
||||
}
|
||||
|
||||
if (rawTags.length === 0) {
|
||||
return fail(400, { message: 'Elige al menos un género.' });
|
||||
}
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const { data: available } = await api.GET('/genres/available');
|
||||
const options = available ?? [];
|
||||
const known = new Set<string>(options.map((option) => option.genre));
|
||||
if (!rawTags.every((tag) => known.has(tag))) {
|
||||
return fail(400, { message: 'Alguno de los géneros no es válido.' });
|
||||
}
|
||||
const tags = [...new Set(rawTags)] as Genre[];
|
||||
const tags = rawTags as Genre[];
|
||||
const conflict = findConflict(tags, options);
|
||||
if (conflict) {
|
||||
return fail(400, {
|
||||
@@ -75,9 +49,11 @@ export const actions: Actions = {
|
||||
}
|
||||
});
|
||||
|
||||
const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.');
|
||||
const { data: urls, failure: urlsFailure } = requireData(
|
||||
urlsResult,
|
||||
'No se pudieron reservar las URLs de subida.'
|
||||
);
|
||||
if (urlsFailure) return urlsFailure;
|
||||
const urls = urlsResult.data!;
|
||||
|
||||
try {
|
||||
const [audioBuffer, coverBuffer] = await Promise.all([
|
||||
@@ -105,10 +81,12 @@ export const actions: Actions = {
|
||||
}
|
||||
});
|
||||
|
||||
const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.');
|
||||
const { data: track, failure: trackFailure } = requireData(
|
||||
trackResult,
|
||||
'No se pudo crear la pista tras la subida.'
|
||||
);
|
||||
if (trackFailure) return trackFailure;
|
||||
const track = trackResult.data!;
|
||||
|
||||
return { success: true, trackId: track.id, title: track.title };
|
||||
}
|
||||
}, 'Inicia sesión para subir música.')
|
||||
};
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
||||
import Chip from '$lib/components/ui/primitives/Chip.svelte';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
import { formatSize } from '$lib/utils/format';
|
||||
import { genreOptions } from '$lib/data/genres';
|
||||
|
||||
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
||||
type StepState = 'idle' | 'active' | 'done';
|
||||
|
||||
const MAX_TITLE = 100;
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let title = $state('');
|
||||
@@ -70,12 +70,6 @@
|
||||
return s === 'idle' ? '' : 'bg-accent-tint';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes === 0) return '';
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
function pickAudio(file: File) {
|
||||
audioName = file.name;
|
||||
audioSize = file.size;
|
||||
@@ -256,12 +250,12 @@
|
||||
id="title"
|
||||
name="title"
|
||||
bind:value={title}
|
||||
maxlength={MAX_TITLE}
|
||||
maxlength={LIMITS.trackTitle}
|
||||
placeholder="Nombre de la canción"
|
||||
/>
|
||||
{#snippet hint()}
|
||||
<p>Se usa como nombre público de la pista en el catálogo.</p>
|
||||
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
|
||||
<span class="shrink-0 tabular-nums">{title.length}/{LIMITS.trackTitle}</span>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
@@ -299,14 +293,9 @@
|
||||
</Surface>
|
||||
|
||||
<Checkbox bind:checked={acceptedTerms}>
|
||||
Acepto los
|
||||
<a
|
||||
href="/terms"
|
||||
class="text-accent-soft underline underline-offset-2 transition hover:brightness-110"
|
||||
>
|
||||
términos y condiciones
|
||||
</a>
|
||||
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
|
||||
Acepto los términos y condiciones al subir {title.trim() !== ''
|
||||
? `«${title}»`
|
||||
: 'esta canción'}.
|
||||
</Checkbox>
|
||||
|
||||
{#if status === 'uploading'}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||
import { toPlaylistSummary } from '$lib/server/mappers';
|
||||
import { COUNT_ONLY_PAGE_SIZE, PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config';
|
||||
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
||||
|
||||
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const viewer = optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
|
||||
const [profileRes, playlistsRes] = await Promise.all([
|
||||
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
|
||||
@@ -26,20 +26,28 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
||||
playlistItems.map((playlist) =>
|
||||
api
|
||||
.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } }
|
||||
params: {
|
||||
path: { playlistId: playlist.id },
|
||||
query: { pageNumber: 1, pageSize: COUNT_ONLY_PAGE_SIZE }
|
||||
}
|
||||
})
|
||||
.then((res) => Number(res.data?.totalItemCount ?? 0))
|
||||
)
|
||||
);
|
||||
|
||||
const playlists = playlistItems.map((playlist, i) => ({
|
||||
...playlist,
|
||||
trackCount: tracksByPlaylist[i]
|
||||
}));
|
||||
const playlists = playlistItems.map((playlist, i) =>
|
||||
toPlaylistSummary(playlist, tracksByPlaylist[i])
|
||||
);
|
||||
|
||||
const isOwnProfile = viewer !== null && viewer.sub === params.id;
|
||||
|
||||
return { profile, playlists, isOwnProfile, isAnonymous: viewer === null };
|
||||
return {
|
||||
profile,
|
||||
followersCount: Number(profile.followersCount),
|
||||
playlists,
|
||||
isOwnProfile,
|
||||
isAnonymous: viewer === null
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet followStats()}
|
||||
{@const followers = plural(Number(profile.followersCount), 'seguidor', 'seguidores')}
|
||||
{@const followers = plural(data.followersCount, 'seguidor', 'seguidores')}
|
||||
{#if data.isOwnProfile || profile.canViewFollowers}
|
||||
<a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
|
||||
{:else}
|
||||
@@ -60,7 +60,7 @@
|
||||
<MediaCard
|
||||
href="/playlists/{playlist.id}"
|
||||
title={playlist.name}
|
||||
subtitle={playlistMeta(Number(playlist.trackCount))}
|
||||
subtitle={playlistMeta(playlist.trackCount)}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||
import { toPage } from '$lib/server/mappers';
|
||||
import { fetchFollowList } from '$lib/server/follows';
|
||||
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
||||
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const viewer = optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
|
||||
const [profileRes, listRes] = await Promise.all([
|
||||
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
|
||||
@@ -20,13 +20,10 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
||||
return {
|
||||
list: params.list,
|
||||
profile,
|
||||
users: forbidden ? null : unwrapOrError(listRes, 'No se pudo cargar la lista.'),
|
||||
users: forbidden
|
||||
? null
|
||||
: toPage(unwrapOrError(listRes, 'No se pudo cargar la lista.'), (item) => item),
|
||||
forbidden,
|
||||
viewerId: viewer?.sub ?? null
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
follow: followUserAction,
|
||||
unfollow: unfollowUserAction
|
||||
};
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
|
||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||
import UserRow from '$lib/components/ui/media/UserRow.svelte';
|
||||
import { appendUnique } from '$lib/data/collections';
|
||||
import { plural } from '$lib/utils/format';
|
||||
|
||||
let { data, form } = $props();
|
||||
let { data } = $props();
|
||||
|
||||
type ListUser = NonNullable<typeof data.users>['items'][number];
|
||||
|
||||
@@ -21,13 +20,13 @@
|
||||
|
||||
$effect(() => {
|
||||
items = data.users?.items ?? [];
|
||||
page = Number(data.users?.pageNumber ?? 1);
|
||||
page = data.users?.pageNumber ?? 1;
|
||||
hasMore = data.users?.hasNextPage ?? false;
|
||||
});
|
||||
|
||||
const isFollowers = $derived(data.list === 'followers');
|
||||
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
|
||||
const total = $derived(Number(data.users?.totalItemCount ?? 0));
|
||||
const total = $derived(data.users?.totalItemCount ?? 0);
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
@@ -37,7 +36,7 @@
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const next = (await res.json()) as NonNullable<typeof data.users>;
|
||||
items = appendUnique(items, next.items, (u) => u.id);
|
||||
page = Number(next.pageNumber);
|
||||
page = next.pageNumber;
|
||||
hasMore = next.hasNextPage;
|
||||
} catch {
|
||||
hasMore = false;
|
||||
@@ -62,10 +61,6 @@
|
||||
: plural(total, 'usuario', 'usuarios')}
|
||||
/>
|
||||
|
||||
{#if form?.message}
|
||||
<Alert class="mb-4">{form.message}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if data.forbidden}
|
||||
<EmptyState
|
||||
icon={Lock}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p class="mb-6.5 text-body text-fg-2">
|
||||
{page.status === 404
|
||||
? 'Comprueba el enlace o vuelve al inicio.'
|
||||
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'}
|
||||
: 'Algo salió mal al cargar la página. Inténtalo de nuevo en unos momentos.'}
|
||||
</p>
|
||||
<Button href="/" variant="secondary">Volver</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,92 +1,76 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
import { apiFor, loginRedirect } from '$lib/server/api';
|
||||
import { toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers';
|
||||
import { authConfig } from '$lib/server/config';
|
||||
import { LIKED_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/auth']);
|
||||
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
||||
|
||||
async function fetchSidebarPlaylists(
|
||||
fetchFn: typeof fetch,
|
||||
accessToken: string | null,
|
||||
userId: string
|
||||
) {
|
||||
try {
|
||||
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
|
||||
const { data } = await api.GET('/playlists/users/{userId}', {
|
||||
params: {
|
||||
path: { userId },
|
||||
query: { pageNumber: 1, pageSize: SIDEBAR_PLAYLISTS_LIMIT }
|
||||
}
|
||||
});
|
||||
return data?.items ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | null) {
|
||||
try {
|
||||
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
|
||||
const { data } = await api.GET('/likes', {
|
||||
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
|
||||
});
|
||||
return data?.items ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLastPlayedTrack(
|
||||
fetchFn: typeof fetch,
|
||||
accessToken: string | null,
|
||||
userId: string
|
||||
) {
|
||||
try {
|
||||
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
|
||||
const { data } = await api.GET('/users/{id}/last-listened-track', {
|
||||
params: { path: { id: userId } }
|
||||
});
|
||||
return data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function orFallback<T>(load: Promise<T>, fallback: T): Promise<T> {
|
||||
return load.catch(() => fallback);
|
||||
}
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
||||
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
|
||||
|
||||
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
|
||||
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
loginRedirect(url);
|
||||
}
|
||||
|
||||
const accountUrl = authConfig.issuer
|
||||
? `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`
|
||||
: null;
|
||||
const accountUrl = `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`;
|
||||
|
||||
if (!locals.user) {
|
||||
return {
|
||||
user: locals.user,
|
||||
allowAnonymousListening,
|
||||
sidebarPlaylists: [],
|
||||
userPlaylists: [],
|
||||
userPlaylistsTotal: 0,
|
||||
likedTracks: [],
|
||||
lastPlayedTrack: null,
|
||||
accountUrl
|
||||
};
|
||||
}
|
||||
|
||||
const [sidebarPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([
|
||||
fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub),
|
||||
fetchLikedTracks(fetch, locals.accessToken),
|
||||
fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub)
|
||||
const api = apiFor({ fetch, locals });
|
||||
const userId = locals.user.sub;
|
||||
|
||||
const [userPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([
|
||||
orFallback(
|
||||
api
|
||||
.GET('/playlists/users/{userId}', {
|
||||
params: {
|
||||
path: { userId },
|
||||
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
|
||||
}
|
||||
})
|
||||
.then(({ data }) => ({
|
||||
items: (data?.items ?? []).map(toPlaylist),
|
||||
total: Number(data?.totalItemCount ?? 0)
|
||||
})),
|
||||
{ items: [], total: 0 }
|
||||
),
|
||||
orFallback(
|
||||
api
|
||||
.GET('/likes', {
|
||||
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
|
||||
})
|
||||
.then(({ data }) => (data?.items ?? []).map(toLikedTrack)),
|
||||
[]
|
||||
),
|
||||
orFallback(
|
||||
api
|
||||
.GET('/users/{id}/last-listened-track', { params: { path: { id: userId } } })
|
||||
.then(({ data }) => (data ? toTrack(data) : null)),
|
||||
null
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
allowAnonymousListening,
|
||||
sidebarPlaylists,
|
||||
userPlaylists: userPlaylists.items,
|
||||
userPlaylistsTotal: userPlaylists.total,
|
||||
likedTracks,
|
||||
lastPlayedTrack,
|
||||
accountUrl
|
||||
|
||||
@@ -41,10 +41,11 @@
|
||||
|
||||
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
|
||||
|
||||
let wasPlaying = false;
|
||||
let queueAutoOpened = false;
|
||||
$effect(() => {
|
||||
if (player.playing && !wasPlaying) queuePanel.show();
|
||||
wasPlaying = player.playing;
|
||||
if (!player.playing || queueAutoOpened) return;
|
||||
queueAutoOpened = true;
|
||||
queuePanel.show();
|
||||
});
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
@@ -107,7 +108,11 @@
|
||||
<div class="app-backdrop"></div>
|
||||
<div class="app-vignette"></div>
|
||||
|
||||
<Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} />
|
||||
<Sidebar
|
||||
user={data.user}
|
||||
playlists={data.userPlaylists ?? []}
|
||||
playlistCount={data.userPlaylistsTotal ?? 0}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1">
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser } from '$lib/server/api';
|
||||
import { apiFor, requireUser } from '$lib/server/api';
|
||||
import { toMix, toPlaylist, toTrack } from '$lib/server/mappers';
|
||||
import { addTrackAction } from '$lib/server/playlistActions';
|
||||
import {
|
||||
HOME_LATEST_PAGE_SIZE,
|
||||
HOME_MIXES_LIMIT,
|
||||
HOME_SHELF_LIMIT,
|
||||
HOME_SPOTLIGHT_TRACKS_LIMIT
|
||||
} from '$lib/config';
|
||||
import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config';
|
||||
import { pickGreeting } from '$lib/server/greeting';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
if (!locals.user) redirect(302, '/explore');
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
|
||||
const latestPromise = api.GET('/tracks', {
|
||||
params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
|
||||
});
|
||||
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||
params: {
|
||||
path: { userId: user.sub },
|
||||
@@ -30,17 +23,19 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
});
|
||||
const listeningStatsPromise = api
|
||||
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
|
||||
.then((res) => res.data ?? { tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 })
|
||||
.then((res) => ({
|
||||
tracksThisWeek: Number(res.data?.tracksThisWeek ?? 0),
|
||||
secondsThisWeek: Number(res.data?.secondsThisWeek ?? 0),
|
||||
streakDays: Number(res.data?.streakDays ?? 0)
|
||||
}))
|
||||
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
|
||||
|
||||
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||
latestPromise,
|
||||
const [playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||
playlistsPromise,
|
||||
mixesPromise,
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
const playlistItems = playlists.data?.items ?? [];
|
||||
const latestItems = (latest.data?.items ?? []).map((item) => item.track);
|
||||
const playlistItems = (playlists.data?.items ?? []).map(toPlaylist);
|
||||
|
||||
const spotlightPlaylist = playlistItems[0] ?? null;
|
||||
const spotlightTracksPromise = spotlightPlaylist
|
||||
@@ -52,7 +47,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
}
|
||||
})
|
||||
.then((res) => ({
|
||||
items: res.data?.items ?? [],
|
||||
items: (res.data?.items ?? []).map(toTrack),
|
||||
totalCount: Number(res.data?.totalItemCount ?? 0)
|
||||
}))
|
||||
.catch(() => ({ items: [], totalCount: 0 }))
|
||||
@@ -60,11 +55,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
|
||||
return {
|
||||
greeting: pickGreeting(new Date()),
|
||||
recentlyPlayed: recentlyPlayed.data ?? [],
|
||||
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT),
|
||||
recentlyPlayed: (recentlyPlayed.data ?? []).map(toTrack),
|
||||
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT).map(toMix),
|
||||
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
|
||||
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
|
||||
newReleases: latestItems,
|
||||
spotlightPlaylist,
|
||||
spotlightTracks: spotlightTracksPromise,
|
||||
listeningStats: listeningStatsPromise
|
||||
|
||||
@@ -3,23 +3,14 @@
|
||||
import History from '@lucide/svelte/icons/history';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import {
|
||||
player,
|
||||
isQueueCurrent,
|
||||
playAllOrToggle,
|
||||
playShuffled,
|
||||
toQueueItem,
|
||||
trackFromQueueItem,
|
||||
type ApiTrackLike
|
||||
} from '$lib/player/player.svelte';
|
||||
import { pressable } from '$lib/actions/pressable';
|
||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||
import type { Track } from '$lib/types';
|
||||
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
||||
import { mixItemTrack } from '$lib/data/mixes';
|
||||
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/utils/format';
|
||||
import { fetchPlaylistTracks } from '$lib/data/albums';
|
||||
import { fmtTime, fmtDurationLong, plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
|
||||
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
@@ -27,92 +18,40 @@
|
||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import { createTrackMenu } from '$lib/state/menus.svelte';
|
||||
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
|
||||
import { HOME_CONTINUE_LIMIT } from '$lib/config';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const trackMenu = createTrackMenu();
|
||||
|
||||
type SpotlightTrack = Awaited<typeof data.spotlightTracks>['items'][number];
|
||||
type SpotlightRow = { track: SpotlightTrack; seconds: number };
|
||||
let spotlightRows = $state<SpotlightRow[]>([]);
|
||||
let spotlightTotal = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
spotlightRows = [];
|
||||
spotlightTotal = 0;
|
||||
let cancelled = false;
|
||||
data.spotlightTracks.then(({ items, totalCount }) => {
|
||||
if (cancelled) return;
|
||||
spotlightRows = items.map((track) => ({
|
||||
track,
|
||||
seconds: Number(track.duration)
|
||||
}));
|
||||
spotlightTotal = totalCount;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const mixes = $derived(data.mixes);
|
||||
const playlists = $derived(data.playlists);
|
||||
const spotlight = $derived(data.spotlightPlaylist);
|
||||
const spotlightItems = $derived(spotlightRows.map((row) => row.track));
|
||||
const spotlightDurationSeconds = $derived(
|
||||
spotlightRows.reduce((sum, row) => sum + row.seconds, 0)
|
||||
);
|
||||
const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length);
|
||||
const spotlightVisibilityLabel = $derived(
|
||||
spotlight?.visibility === 'Public' ? 'Pública' : 'Privada'
|
||||
);
|
||||
const spotlightMeta = $derived(
|
||||
spotlightTrackCount === 0
|
||||
? spotlightVisibilityLabel
|
||||
: [
|
||||
plural(spotlightTrackCount, 'canción', 'canciones'),
|
||||
fmtDurationLong(spotlightDurationSeconds),
|
||||
spotlightVisibilityLabel
|
||||
].join(' · ')
|
||||
);
|
||||
|
||||
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
|
||||
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
|
||||
|
||||
const popularTracks = $derived<ApiTrackLike[]>(
|
||||
recentPool.map(trackFromQueueItem).slice(0, HOME_POPULAR_MAX)
|
||||
);
|
||||
|
||||
const spotlightQueue = $derived(spotlightItems.map(toQueueItem));
|
||||
const spotlightIsCurrent = $derived(isQueueCurrent(spotlightQueue));
|
||||
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
|
||||
|
||||
function playContinue(index: number) {
|
||||
player.playOrToggle(continueItems, index);
|
||||
}
|
||||
function playPopular(index: number) {
|
||||
player.playOrToggle(popularTracks.map(toQueueItem), index);
|
||||
function playSpotlightTrack(items: Track[], index: number) {
|
||||
player.playOrToggle(items, index);
|
||||
}
|
||||
function playSpotlightTrack(index: number) {
|
||||
player.playOrToggle(spotlightQueue, index);
|
||||
async function playSpotlight(previewItems: Track[]) {
|
||||
if (!spotlight) return;
|
||||
if (isQueueCurrent(previewItems)) {
|
||||
player.toggle();
|
||||
return;
|
||||
}
|
||||
playAllOrToggle(await fetchPlaylistTracks(spotlight.id));
|
||||
}
|
||||
function playSpotlight() {
|
||||
playAllOrToggle(spotlightQueue);
|
||||
}
|
||||
function shuffleSpotlight() {
|
||||
playShuffled(spotlightQueue);
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
|
||||
trackMenu.open(event, track);
|
||||
async function shuffleSpotlight() {
|
||||
if (!spotlight) return;
|
||||
playShuffled(await fetchPlaylistTracks(spotlight.id));
|
||||
}
|
||||
|
||||
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
player.playQueue(
|
||||
mix.items.map((item) => toQueueItem(mixItemTrack(item))),
|
||||
0
|
||||
);
|
||||
player.playQueue(mix.tracks, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -149,10 +88,12 @@
|
||||
</p>
|
||||
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
|
||||
{#if spotlight}
|
||||
<Button variant="primary" onclick={playSpotlight}>
|
||||
{heroPlaying ? 'Pausar' : 'Reanudar'}
|
||||
{spotlight.name}
|
||||
</Button>
|
||||
{#await data.spotlightTracks then { items: previewItems }}
|
||||
<Button variant="primary" onclick={() => playSpotlight(previewItems)}>
|
||||
{isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'}
|
||||
{spotlight.name}
|
||||
</Button>
|
||||
{/await}
|
||||
{/if}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
</div>
|
||||
@@ -162,7 +103,7 @@
|
||||
{@const statsList = [
|
||||
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
|
||||
{
|
||||
value: fmtDurationLong(Number(stats.secondsThisWeek)),
|
||||
value: fmtDurationLong(stats.secondsThisWeek),
|
||||
label: 'Tiempo de escucha esta semana'
|
||||
},
|
||||
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
|
||||
@@ -181,14 +122,14 @@
|
||||
|
||||
<div class="mt-10 flex flex-col gap-section">
|
||||
<section>
|
||||
<SectionHeading title="Continuar escuchando" subtitle="Retomalo donde lo dejaste" />
|
||||
<SectionHeading title="Continuar escuchando" subtitle="Retómalo donde lo dejaste" />
|
||||
{#if continueItems.length > 0}
|
||||
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{#each continueItems as item, i (item.id)}
|
||||
{@const isCurrent = player.current.id === item.id}
|
||||
{@const isCurrent = player.currentId === item.id}
|
||||
<ListRow
|
||||
onclick={() => playContinue(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, trackFromQueueItem(item))}
|
||||
oncontextmenu={(e) => trackMenu.open(e, item)}
|
||||
active={isCurrent}
|
||||
size="lg"
|
||||
variant="card"
|
||||
@@ -209,66 +150,75 @@
|
||||
|
||||
{#if spotlight}
|
||||
<section>
|
||||
<div
|
||||
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
|
||||
>
|
||||
<div class="flex flex-col justify-center p-8 sm:p-9">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
use:pressable={() => goto(`/playlists/${spotlight.id}`)}
|
||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||
>
|
||||
<Artwork
|
||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
||||
spotlight.updatedAt
|
||||
)}"
|
||||
trackIds={spotlight.coverTrackIds}
|
||||
size="2xl"
|
||||
alt={spotlight.name}
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-3">
|
||||
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
|
||||
<h3 class="truncate text-display-2 text-fg">
|
||||
{spotlight.name}
|
||||
</h3>
|
||||
<p class="text-sm text-fg-2">{spotlightMeta}</p>
|
||||
{#await data.spotlightTracks then { items: previewItems, totalCount }}
|
||||
{@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'}
|
||||
{@const meta =
|
||||
totalCount === 0
|
||||
? visibilityLabel
|
||||
: `${plural(totalCount, 'canción', 'canciones')} · ${visibilityLabel}`}
|
||||
<div
|
||||
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
|
||||
>
|
||||
<div class="flex flex-col justify-center p-8 sm:p-9">
|
||||
<a
|
||||
href="/playlists/{spotlight.id}"
|
||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||
>
|
||||
<Artwork
|
||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
||||
spotlight.updatedAt
|
||||
)}"
|
||||
trackIds={spotlight.coverTrackIds}
|
||||
size="2xl"
|
||||
alt={spotlight.name}
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-3">
|
||||
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
|
||||
<h3 class="truncate text-display-2 text-fg">
|
||||
{spotlight.name}
|
||||
</h3>
|
||||
<p class="text-sm text-fg-2">{meta}</p>
|
||||
</div>
|
||||
</a>
|
||||
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
|
||||
{spotlight.description || 'Tu colección, siempre a mano.'}
|
||||
</p>
|
||||
<div class="mt-5 flex gap-2.5">
|
||||
<Button variant="accent" onclick={() => playSpotlight(previewItems)}
|
||||
>Reproducir</Button
|
||||
>
|
||||
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
|
||||
{spotlight.description || 'Tu colección, siempre a mano.'}
|
||||
</p>
|
||||
<div class="mt-5 flex gap-2.5">
|
||||
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
||||
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||
<div class="flex flex-col gap-2 p-5.5 sm:p-6">
|
||||
{#each previewItems as track, i (track.id)}
|
||||
<ListRow
|
||||
onclick={() => playSpotlightTrack(previewItems, i)}
|
||||
active={player.currentId === track.id}
|
||||
size="sm"
|
||||
title={track.title}
|
||||
subtitle={track.artist}
|
||||
subtitleHref={track.ownerUserId}
|
||||
explicit={track.explicit}
|
||||
>
|
||||
{#snippet leading()}
|
||||
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
|
||||
>{i + 1}</span
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
|
||||
>{fmtTime(track.duration)}</span
|
||||
>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
{:else}
|
||||
<p class="p-2 text-sm text-fg-2">Esta playlist todavía no tiene canciones.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-5.5 sm:p-6">
|
||||
{#each spotlightRows as { track, seconds }, i (track.id)}
|
||||
<ListRow
|
||||
onclick={() => playSpotlightTrack(i)}
|
||||
active={player.current.id === track.id}
|
||||
size="sm"
|
||||
title={track.title}
|
||||
subtitle={track.artist}
|
||||
subtitleHref={track.ownerUserId}
|
||||
explicit={track.isExplicit}
|
||||
>
|
||||
{#snippet leading()}
|
||||
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
|
||||
>{i + 1}</span
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(seconds)}</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
{:else}
|
||||
<p class="p-2 text-sm text-fg-2">Esta playlist todavía no tiene canciones.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -281,7 +231,7 @@
|
||||
href="/mixes/{mix.id}"
|
||||
title={mix.title}
|
||||
subtitle={mix.subtitle}
|
||||
trackIds={mix.items.map((item) => item.trackId)}
|
||||
trackIds={mix.tracks.map((track) => track.id)}
|
||||
onPlay={(event) => playMix(mix, event)}
|
||||
index={i}
|
||||
/>
|
||||
@@ -296,45 +246,6 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
|
||||
{#if popularTracks.length > 0}
|
||||
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
||||
{#each popularTracks as track, i (track.id)}
|
||||
{@const isCurrent = player.current.id === track.id}
|
||||
<ListRow
|
||||
onclick={() => playPopular(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, track)}
|
||||
active={isCurrent}
|
||||
size="sm"
|
||||
title={track.title}
|
||||
subtitle={track.artist}
|
||||
subtitleHref={track.ownerUserId}
|
||||
explicit={track.isExplicit}
|
||||
trackId={track.id}
|
||||
>
|
||||
{#snippet leading()}
|
||||
<span class="flex h-3.5 w-icon-md shrink-0 items-end justify-center">
|
||||
{#if isCurrent}
|
||||
<EqBars size={13} paused={!player.playing} />
|
||||
{:else}
|
||||
<span class="text-xs text-fg-2 tabular-nums">{i + 1}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
||||
{fmtPlays(track.listensCount)}
|
||||
</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{@render noListeningHistory()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeading
|
||||
title="Tus playlists"
|
||||
@@ -384,9 +295,9 @@
|
||||
]}
|
||||
playlistAction={{
|
||||
action: '?/addTrack',
|
||||
fields: { trackId: String(trackMenu.state.track.id) },
|
||||
fields: { trackId: trackMenu.state.track.id },
|
||||
label: 'Añadir a una playlist'
|
||||
}}
|
||||
{playlists}
|
||||
playlists={data.userPlaylists}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { toPage, toTrack } from '$lib/server/mappers';
|
||||
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
const result = await api.GET('/albums/{albumId}/tracks', {
|
||||
params: {
|
||||
path: { albumId: params.id },
|
||||
@@ -12,5 +13,5 @@ export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||
}
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudo cargar el álbum.'));
|
||||
return json(toPage(unwrapOrError(result, 'No se pudo cargar el álbum.'), toTrack));
|
||||
};
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
import { apiFor } from '$lib/server/api';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
|
||||
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const result = await api.POST('/likes/toggle', { body });
|
||||
const payload = await request.json().catch(() => null);
|
||||
const trackId = payload?.trackId;
|
||||
if (typeof trackId !== 'string' || trackId === '')
|
||||
return json({ message: 'Canción inválida.' }, { status: 400 });
|
||||
|
||||
const api = apiFor({ fetch, locals });
|
||||
const result = await api.POST('/likes/toggle', { body: { trackId } });
|
||||
|
||||
if (result.error || result.data === undefined)
|
||||
return json({ message: 'No se pudo actualizar Me gusta.' }, { status: 502 });
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
import { apiFor } from '$lib/server/api';
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => {
|
||||
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
|
||||
|
||||
const { playedSeconds } = (await request.json()) as { playedSeconds?: number };
|
||||
const payload = (await request.json().catch(() => null)) as { playedSeconds?: unknown } | null;
|
||||
const playedSeconds = payload?.playedSeconds;
|
||||
if (typeof playedSeconds !== 'number' || !Number.isFinite(playedSeconds) || playedSeconds < 0)
|
||||
return json({ message: 'Tiempo inválido.' }, { status: 400 });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const api = apiFor({ fetch, locals });
|
||||
const result = await api.PUT('/tracks/listens/{listenId}/progress', {
|
||||
params: { path: { listenId: params.id } },
|
||||
body: { playedSeconds }
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { toPage, toTrack } from '$lib/server/mappers';
|
||||
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||
const api = apiFor({ fetch, locals });
|
||||
const result = await api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: {
|
||||
path: { playlistId: params.id },
|
||||
query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE }
|
||||
}
|
||||
});
|
||||
|
||||
return json(toPage(unwrapOrError(result, 'No se pudo cargar la playlist.'), toTrack));
|
||||
};
|
||||
@@ -1,18 +1,26 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { toPage, toTrack } from '$lib/server/mappers';
|
||||
import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
||||
const name = url.searchParams.get('name')?.trim() || undefined;
|
||||
const genre = url.searchParams.get('genre')?.trim() || undefined;
|
||||
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
|
||||
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
|
||||
const pageSize = Math.min(
|
||||
MAX_PAGE_SIZE,
|
||||
Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE)
|
||||
);
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
const result = await api.GET('/tracks', {
|
||||
params: { query: { name, genre, pageNumber, pageSize } }
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));
|
||||
return json(
|
||||
toPage(unwrapOrError(result, 'No se pudieron cargar las canciones.'), (item) =>
|
||||
toTrack(item.track)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
const result = await api.GET('/tracks/{id}/stream', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { toPage } from '$lib/server/mappers';
|
||||
import { fetchFollowList } from '$lib/server/follows';
|
||||
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
|
||||
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const api = apiFor({ fetch, locals });
|
||||
const result = await fetchFollowList(
|
||||
api,
|
||||
params.list,
|
||||
@@ -15,5 +16,10 @@ export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
|
||||
FOLLOW_LIST_PAGE_SIZE
|
||||
);
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status));
|
||||
return json(
|
||||
toPage(
|
||||
unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status),
|
||||
(user) => user
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user