Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b011ccdd5b | ||
|
|
1df7f6ccf6 | ||
|
|
2915737f03 | ||
|
|
5b2a0e4d46 | ||
|
|
37fc229bf6 | ||
|
|
bc43b4b6eb | ||
|
|
dda8c30d45 | ||
|
|
906f38140a | ||
|
|
3e546d4a1e | ||
|
|
b2f450dcf7 | ||
|
|
87c3f97529 |
@@ -21,6 +21,8 @@ namespace Musify.Application.PlayLists
|
|||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
PlayList = p,
|
PlayList = p,
|
||||||
|
TrackCount = p.PlayListTracks.Count,
|
||||||
|
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||||
CoverTrackIds = p.PlayListTracks
|
CoverTrackIds = p.PlayListTracks
|
||||||
.OrderBy(plt => plt.Position)
|
.OrderBy(plt => plt.Position)
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||||
@@ -36,7 +38,7 @@ namespace Musify.Application.PlayLists
|
|||||||
&& entry.PlayList.UserId != request.RequestingUserId)
|
&& entry.PlayList.UserId != request.RequestingUserId)
|
||||||
return Error.NotFound();
|
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
|
.Select(p => new
|
||||||
{
|
{
|
||||||
PlayList = p,
|
PlayList = p,
|
||||||
|
TrackCount = p.PlayListTracks.Count,
|
||||||
|
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||||
CoverTrackIds = p.PlayListTracks
|
CoverTrackIds = p.PlayListTracks
|
||||||
.OrderBy(plt => plt.Position)
|
.OrderBy(plt => plt.Position)
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||||
@@ -34,7 +36,7 @@ namespace Musify.Application.PlayLists
|
|||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
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);
|
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ namespace Musify.Application.PlayLists
|
|||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
PlayList = p,
|
PlayList = p,
|
||||||
|
TrackCount = p.PlayListTracks.Count,
|
||||||
|
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||||
CoverTrackIds = p.PlayListTracks
|
CoverTrackIds = p.PlayListTracks
|
||||||
.OrderBy(plt => plt.Position)
|
.OrderBy(plt => plt.Position)
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||||
@@ -55,7 +57,7 @@ namespace Musify.Application.PlayLists
|
|||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
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);
|
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
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.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
@@ -10,7 +12,10 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
string? SmallImageKeyName,
|
string? SmallImageKeyName,
|
||||||
string? MediumImageKeyName,
|
string? MediumImageKeyName,
|
||||||
string? LargeImageKeyName,
|
string? LargeImageKeyName,
|
||||||
|
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||||
PlaylistVisibility Visibility,
|
PlaylistVisibility Visibility,
|
||||||
|
int TrackCount,
|
||||||
|
double DurationSeconds,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
IReadOnlyList<Guid> CoverTrackIds)
|
||||||
@@ -19,6 +24,8 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
|
|
||||||
public static PlayListApplicationResponse FromEntity(
|
public static PlayListApplicationResponse FromEntity(
|
||||||
PlayList playList,
|
PlayList playList,
|
||||||
|
int trackCount = 0,
|
||||||
|
double durationSeconds = 0,
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||||
{
|
{
|
||||||
return new PlayListApplicationResponse(
|
return new PlayListApplicationResponse(
|
||||||
@@ -28,7 +35,10 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
playList.Pictures?.SmallName,
|
playList.Pictures?.SmallName,
|
||||||
playList.Pictures?.MediumName,
|
playList.Pictures?.MediumName,
|
||||||
playList.Pictures?.LargeName,
|
playList.Pictures?.LargeName,
|
||||||
|
playList.UserId,
|
||||||
playList.Visibility,
|
playList.Visibility,
|
||||||
|
trackCount,
|
||||||
|
durationSeconds,
|
||||||
playList.CreatedAt,
|
playList.CreatedAt,
|
||||||
playList.UpdatedAt,
|
playList.UpdatedAt,
|
||||||
coverTrackIds ?? []);
|
coverTrackIds ?? []);
|
||||||
|
|||||||
@@ -112,7 +112,22 @@ namespace Musify.Application.PlayLists
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
|
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(
|
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_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||||
AUTH_POST_LOGOUT_URI=http://localhost:5173/
|
AUTH_POST_LOGOUT_URI=http://localhost:5173/
|
||||||
|
|
||||||
# Secret used to derive the session cookie encryption key (any long random string)
|
# Secret used to derive the session cookie encryption key. Required, at least
|
||||||
SESSION_SECRET=change-me-to-a-long-random-string
|
# 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
|
# Backend API base URL
|
||||||
API_BASE_URL=http://localhost:5111
|
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`.
|
- **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`).
|
- **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`).
|
- **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.
|
- **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.
|
- **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",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.64.0",
|
"typescript-eslint": "^8.64.0",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16",
|
||||||
}
|
"vitest": "^5.0.1"
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
@@ -343,9 +310,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
@@ -1152,6 +1119,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
|
||||||
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
|
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.9",
|
"@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",
|
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
|
||||||
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
|
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
@@ -1440,27 +1409,6 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1565,12 +1513,30 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -1597,6 +1563,7 @@
|
|||||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1658,6 +1625,7 @@
|
|||||||
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
|
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.64.0",
|
"@typescript-eslint/scope-manager": "8.64.0",
|
||||||
"@typescript-eslint/types": "8.64.0",
|
"@typescript-eslint/types": "8.64.0",
|
||||||
@@ -1765,6 +1733,7 @@
|
|||||||
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
|
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
@@ -1843,11 +1812,70 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1891,6 +1919,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -1923,6 +1961,16 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -2063,6 +2111,13 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
@@ -2082,6 +2137,7 @@
|
|||||||
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
@@ -2323,6 +2379,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -3007,9 +3073,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.3",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/sxzz",
|
"https://github.com/sponsors/sxzz",
|
||||||
"https://opencollective.com/debug"
|
"https://opencollective.com/debug"
|
||||||
@@ -3130,9 +3196,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -3160,6 +3226,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3293,6 +3360,7 @@
|
|||||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -3309,6 +3377,7 @@
|
|||||||
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
|
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
@@ -3479,6 +3548,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.9"
|
"@types/estree": "1.0.9"
|
||||||
},
|
},
|
||||||
@@ -3573,6 +3643,13 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/sirv": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||||
@@ -3596,6 +3673,20 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
||||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -3760,6 +3852,26 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -3824,6 +3936,7 @@
|
|||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3885,6 +3998,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -3992,6 +4199,23 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"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": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
|
"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 .",
|
"format": "prettier --write .",
|
||||||
"lint:tokens": "node scripts/check-tokens.mjs",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -33,7 +35,8 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.64.0",
|
"typescript-eslint": "^8.64.0",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^5.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||||
|
|||||||
Vendored
+1
@@ -940,6 +940,7 @@ export interface components {
|
|||||||
smallImageKeyName: null | string;
|
smallImageKeyName: null | string;
|
||||||
mediumImageKeyName: null | string;
|
mediumImageKeyName: null | string;
|
||||||
largeImageKeyName: null | string;
|
largeImageKeyName: null | string;
|
||||||
|
ownerUserId: string;
|
||||||
visibility: components["schemas"]["PlaylistVisibility"];
|
visibility: components["schemas"]["PlaylistVisibility"];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Logo from '../ui/primitives/Logo.svelte';
|
import Logo from '../ui/primitives/Logo.svelte';
|
||||||
|
|
||||||
|
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
||||||
|
|
||||||
interface SidebarPlaylist {
|
interface SidebarPlaylist {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -19,11 +21,14 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
playlists: SidebarPlaylist[];
|
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) {
|
function isActive(href: string) {
|
||||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
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="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
|
||||||
<div class="flex items-center justify-between px-3 pb-1.5">
|
<div class="flex items-center justify-between px-3 pb-1.5">
|
||||||
<span class="text-eyebrow text-fg-2">Tus playlists</span>
|
<span class="text-eyebrow text-fg-2">Tus playlists</span>
|
||||||
{#if playlists.length > 0}
|
{#if visiblePlaylists.length > 0}
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Crear playlist"
|
label="Crear playlist"
|
||||||
tone="plain"
|
plain
|
||||||
size="xs"
|
size="xs"
|
||||||
onclick={() => createPlaylistModal.show()}
|
onclick={() => createPlaylistModal.show()}
|
||||||
>
|
>
|
||||||
@@ -74,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
|
<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}`}
|
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||||
<ListRow
|
<ListRow
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
@@ -91,7 +96,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => createPlaylistModal.show()}
|
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" />
|
<Plus class="size-3.5" />
|
||||||
Crear tu primera lista
|
Crear tu primera lista
|
||||||
|
|||||||
@@ -54,14 +54,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
|
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
|
||||||
<IconButton
|
<IconButton label="Atrás" shape="round" surface size="xs" onclick={() => history.back()}>
|
||||||
label="Atrás"
|
|
||||||
shape="round"
|
|
||||||
tone="subtle"
|
|
||||||
surface
|
|
||||||
size="xs"
|
|
||||||
onclick={() => history.back()}
|
|
||||||
>
|
|
||||||
<ChevronLeft class="size-icon-sm" />
|
<ChevronLeft class="size-icon-sm" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
@@ -91,9 +84,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="ml-auto shrink-0">
|
||||||
|
|
||||||
<div class="shrink-0">
|
|
||||||
{#if user}
|
{#if user}
|
||||||
<AccountMenu {user} {accountUrl}>
|
<AccountMenu {user} {accountUrl}>
|
||||||
{#snippet trigger({ toggle, open })}
|
{#snippet trigger({ toggle, open })}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
|
|
||||||
function seek(value: number) {
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -39,20 +39,20 @@
|
|||||||
</span>
|
</span>
|
||||||
<Slider
|
<Slider
|
||||||
value={player.progress}
|
value={player.progress}
|
||||||
max={player.current.duration}
|
max={player.duration}
|
||||||
label="Barra de progreso"
|
label="Barra de progreso"
|
||||||
oninput={seek}
|
oninput={seek}
|
||||||
variant="seek"
|
variant="seek"
|
||||||
/>
|
/>
|
||||||
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
|
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
|
||||||
{fmtTime(player.current.duration)}
|
{fmtTime(player.duration)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<IconButton
|
||||||
label={player.muted ? 'Activar sonido' : 'Silenciar'}
|
label={player.muted ? 'Activar sonido' : 'Silenciar'}
|
||||||
tone="plain"
|
plain
|
||||||
size="xs"
|
size="xs"
|
||||||
onclick={() => player.toggleMute()}
|
onclick={() => player.toggleMute()}
|
||||||
>
|
>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Cola de reproducción"
|
label="Cola de reproducción"
|
||||||
tone="plain"
|
plain
|
||||||
size="xs"
|
size="xs"
|
||||||
pressed={queuePanel.open}
|
pressed={queuePanel.open}
|
||||||
onclick={() => queuePanel.toggle()}
|
onclick={() => queuePanel.toggle()}
|
||||||
|
|||||||
@@ -48,12 +48,12 @@
|
|||||||
>
|
>
|
||||||
<div class="mb-5 flex items-center justify-between">
|
<div class="mb-5 flex items-center justify-between">
|
||||||
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
|
<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" />
|
<X class="size-icon-md" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if player.current.id}
|
{#if player.current}
|
||||||
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
|
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
|
||||||
<div class="mb-6 surface-active rounded-control p-2.5">
|
<div class="mb-6 surface-active rounded-control p-2.5">
|
||||||
<MediaIdentity
|
<MediaIdentity
|
||||||
|
|||||||
@@ -13,27 +13,20 @@
|
|||||||
|
|
||||||
let { compact = false }: Props = $props();
|
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() {
|
function toggleLike() {
|
||||||
if (!player.current.id) return;
|
if (current) liked.toggle(current);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
||||||
{#if player.current.id}
|
{#if current}
|
||||||
<Artwork
|
<Artwork
|
||||||
trackIds={[player.current.id]}
|
trackIds={[current.id]}
|
||||||
size="sm"
|
size="sm"
|
||||||
alt={player.current.title}
|
alt={current.title}
|
||||||
class="shrink-0 md:size-cover-md"
|
class="shrink-0 md:size-cover-md"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -41,21 +34,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex min-w-0 items-center gap-1.5">
|
<div class="flex min-w-0 items-center gap-1.5">
|
||||||
{#if player.current.explicit}
|
{#if current?.explicit}
|
||||||
<ExplicitBadge />
|
<ExplicitBadge />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="truncate text-sm text-fg">{player.current.title}</span>
|
<span class="truncate text-sm text-fg">{current?.title ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<ArtistLink
|
<ArtistLink
|
||||||
name={player.current.artist || '—'}
|
name={current?.artist || '—'}
|
||||||
ownerUserId={player.current.ownerUserId}
|
ownerUserId={current?.ownerUserId}
|
||||||
class="mt-0.5 text-xs text-fg-2"
|
class="mt-0.5 text-xs text-fg-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
<IconButton
|
<IconButton
|
||||||
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
|
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
|
||||||
tone="plain"
|
plain
|
||||||
size="md"
|
size="md"
|
||||||
pressed={isLiked}
|
pressed={isLiked}
|
||||||
onclick={toggleLike}
|
onclick={toggleLike}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import Field from '../primitives/Field.svelte';
|
import Field from '../primitives/Field.svelte';
|
||||||
import Input from '../primitives/Input.svelte';
|
import Input from '../primitives/Input.svelte';
|
||||||
import Textarea from '../primitives/Textarea.svelte';
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -58,10 +58,13 @@
|
|||||||
>
|
>
|
||||||
{#snippet fields()}
|
{#snippet fields()}
|
||||||
<Field label="Título" for="album-title">
|
<Field label="Título" for="album-title">
|
||||||
|
{#snippet hint()}
|
||||||
|
<span class="ml-auto tabular-nums">{title.length}/{LIMITS.albumTitle}</span>
|
||||||
|
{/snippet}
|
||||||
<Input
|
<Input
|
||||||
id="album-title"
|
id="album-title"
|
||||||
name="title"
|
name="title"
|
||||||
maxlength={200}
|
maxlength={LIMITS.albumTitle}
|
||||||
required
|
required
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
placeholder={titlePlaceholder}
|
placeholder={titlePlaceholder}
|
||||||
@@ -85,7 +88,7 @@
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="album-description"
|
id="album-description"
|
||||||
name="description"
|
name="description"
|
||||||
maxlength={256}
|
maxlength={LIMITS.albumDescription}
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder="¿De qué va este álbum?"
|
placeholder="¿De qué va este álbum?"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -81,10 +81,8 @@
|
|||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
{#if helperText}
|
{#if helperText}
|
||||||
<p class="text-sm text-fg-2">{helperText}</p>
|
<p class="text-sm text-fg-2">{helperText}</p>
|
||||||
{:else}
|
|
||||||
<span></span>
|
|
||||||
{/if}
|
{/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="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||||
<Button type="submit" disabled={submitting || !canSubmit}>
|
<Button type="submit" disabled={submitting || !canSubmit}>
|
||||||
{submitting ? submittingLabel : submitLabel}
|
{submitting ? submittingLabel : submitLabel}
|
||||||
|
|||||||
@@ -37,9 +37,16 @@
|
|||||||
function onInput(event: Event) {
|
function onInput(event: Event) {
|
||||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
releasePreview();
|
||||||
preview = URL.createObjectURL(file);
|
preview = URL.createObjectURL(file);
|
||||||
onselect?.(file);
|
onselect?.(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function releasePreview() {
|
||||||
|
if (preview !== '') URL.revokeObjectURL(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => releasePreview);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
import Textarea from '../primitives/Textarea.svelte';
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
import SegmentedControl from '../primitives/SegmentedControl.svelte';
|
import SegmentedControl from '../primitives/SegmentedControl.svelte';
|
||||||
|
|
||||||
const NAME_MAX_LENGTH = 60;
|
import { LIMITS } from '$lib/validation';
|
||||||
const DESCRIPTION_MAX_LENGTH = 300;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -61,12 +60,12 @@
|
|||||||
<input type="hidden" name="visibility" value={visibility} />
|
<input type="hidden" name="visibility" value={visibility} />
|
||||||
<Field label="Nombre" for="playlist-name">
|
<Field label="Nombre" for="playlist-name">
|
||||||
{#snippet hint()}
|
{#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}
|
{/snippet}
|
||||||
<Input
|
<Input
|
||||||
id="playlist-name"
|
id="playlist-name"
|
||||||
name="name"
|
name="name"
|
||||||
maxlength={NAME_MAX_LENGTH}
|
maxlength={LIMITS.playlistName}
|
||||||
required
|
required
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
placeholder={namePlaceholder}
|
placeholder={namePlaceholder}
|
||||||
@@ -77,7 +76,7 @@
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="playlist-description"
|
id="playlist-description"
|
||||||
name="description"
|
name="description"
|
||||||
maxlength={DESCRIPTION_MAX_LENGTH}
|
maxlength={LIMITS.playlistDescription}
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<a
|
<a
|
||||||
href="/user/{ownerUserId}"
|
href="/user/{ownerUserId}"
|
||||||
onclick={(e) => e.stopPropagation()}
|
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}
|
{name}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
size: Size;
|
size: Size;
|
||||||
shape?: 'auto' | 'round';
|
shape?: 'auto' | 'round';
|
||||||
imageSize?: 'small' | 'medium' | 'large';
|
imageSize?: 'small' | 'medium' | 'large';
|
||||||
fallback?: 'music' | 'gradient' | 'none';
|
|
||||||
alt?: string;
|
alt?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
@@ -21,7 +20,6 @@
|
|||||||
size,
|
size,
|
||||||
shape = 'auto',
|
shape = 'auto',
|
||||||
imageSize,
|
imageSize,
|
||||||
fallback = 'music',
|
|
||||||
alt = '',
|
alt = '',
|
||||||
class: klass = '',
|
class: klass = '',
|
||||||
children
|
children
|
||||||
@@ -116,7 +114,7 @@
|
|||||||
onerror={() => (singleFailed = true)}
|
onerror={() => (singleFailed = true)}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else if fallback === 'music'}
|
{:else}
|
||||||
<div class="grid h-full w-full place-items-center bg-surface">
|
<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">
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2">
|
||||||
<path
|
<path
|
||||||
@@ -124,10 +122,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
size?: number;
|
|
||||||
barWidth?: number;
|
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
overlay?: boolean;
|
overlay?: boolean;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { paused = false, overlay = false, class: klass = '' }: Props = $props();
|
||||||
size = 13,
|
|
||||||
barWidth = 2,
|
|
||||||
paused = false,
|
|
||||||
overlay = false,
|
|
||||||
class: klass = ''
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const delays = [0, 0.3, 0.6];
|
const delays = [0, 0.3, 0.6];
|
||||||
</script>
|
</script>
|
||||||
@@ -30,11 +22,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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)}
|
{#each delays as delay (delay)}
|
||||||
<span
|
<span
|
||||||
class="rounded-full bg-accent"
|
class="h-full w-0.5 rounded-full bg-accent"
|
||||||
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||||
? 'paused'
|
? 'paused'
|
||||||
: 'running'}"
|
: 'running'}"
|
||||||
></span>
|
></span>
|
||||||
|
|||||||
@@ -5,12 +5,11 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
following: boolean;
|
following: boolean;
|
||||||
userId?: string;
|
|
||||||
invalidateAll?: boolean;
|
invalidateAll?: boolean;
|
||||||
class?: string;
|
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 following = $state(untrack(() => initial));
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
@@ -30,9 +29,6 @@
|
|||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if userId}
|
|
||||||
<input type="hidden" name="userId" value={userId} />
|
|
||||||
{/if}
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
art?: Snippet;
|
art?: Snippet;
|
||||||
overlay?: Snippet;
|
overlay?: Snippet;
|
||||||
badge?: Snippet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -34,8 +33,7 @@
|
|||||||
titleClass = '',
|
titleClass = '',
|
||||||
class: klass = '',
|
class: klass = '',
|
||||||
art,
|
art,
|
||||||
overlay,
|
overlay
|
||||||
badge
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,7 +59,6 @@
|
|||||||
<span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}"
|
<span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}"
|
||||||
>{title}</span
|
>{title}</span
|
||||||
>
|
>
|
||||||
{@render badge?.()}
|
|
||||||
</div>
|
</div>
|
||||||
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
|
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
|
||||||
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
||||||
|
|
||||||
export interface TrackListTrack {
|
export type TrackListTrack = Track & { date?: string | number };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
||||||
added: { label: 'Añadida', width: '124px' },
|
added: { label: 'Añadida', width: '124px' },
|
||||||
@@ -108,7 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
||||||
{#each tracks as track, i (track.id)}
|
{#each tracks as track, i (track.id)}
|
||||||
{@const active = player.current.id === track.id}
|
{@const active = player.currentId === track.id}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -138,8 +129,6 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
use:pressable={() => onPlay(i)}
|
use:pressable={() => onPlay(i)}
|
||||||
onclick={(event) => event.stopPropagation()}
|
|
||||||
onkeydown={(event) => event.stopPropagation()}
|
|
||||||
class="flex min-w-0 flex-1 text-left"
|
class="flex min-w-0 flex-1 text-left"
|
||||||
>
|
>
|
||||||
<MediaIdentity
|
<MediaIdentity
|
||||||
@@ -147,7 +136,7 @@
|
|||||||
title={track.title}
|
title={track.title}
|
||||||
artist={track.artist}
|
artist={track.artist}
|
||||||
ownerUserId={track.ownerUserId}
|
ownerUserId={track.ownerUserId}
|
||||||
explicit={track.isExplicit ?? track.explicit ?? false}
|
explicit={track.explicit}
|
||||||
{active}
|
{active}
|
||||||
>
|
>
|
||||||
{#snippet overlay()}
|
{#snippet overlay()}
|
||||||
@@ -160,26 +149,18 @@
|
|||||||
{#each columns as column (column)}
|
{#each columns as column (column)}
|
||||||
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
||||||
{#if column === 'plays'}
|
{#if column === 'plays'}
|
||||||
{Number(track.listensCount ?? 0)}
|
{track.listensCount}
|
||||||
{:else if column === 'added' && track.addedAt}
|
{:else if track.date}
|
||||||
{fmtDate(track.addedAt)}
|
{fmtDate(track.date)}
|
||||||
{:else if column === 'uploaded' && track.uploadedAt}
|
|
||||||
{fmtDate(track.uploadedAt)}
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
||||||
{fmtTime(Number(track.duration))}
|
{fmtTime(track.duration)}
|
||||||
</span>
|
</span>
|
||||||
{#if rowAction}
|
{#if rowAction}
|
||||||
{#if rowAction.action}
|
{#if rowAction.action}
|
||||||
<form
|
<form method="POST" action={rowAction.action} use:enhance>
|
||||||
method="POST"
|
|
||||||
action={rowAction.action}
|
|
||||||
use:enhance={() =>
|
|
||||||
async ({ update }) =>
|
|
||||||
update()}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="trackId" value={track.id} />
|
<input type="hidden" name="trackId" value={track.id} />
|
||||||
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
|
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
|
||||||
<rowAction.icon class="size-icon-sm" />
|
<rowAction.icon class="size-icon-sm" />
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
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()}
|
onclick={() => themeMode.toggle()}
|
||||||
/>
|
/>
|
||||||
<form method="POST" action="/auth/logout" data-sveltekit-reload>
|
<form method="POST" action="/auth/logout" data-sveltekit-reload>
|
||||||
|
|||||||
@@ -9,15 +9,19 @@
|
|||||||
description: string;
|
description: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
action: 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>
|
</script>
|
||||||
|
|
||||||
<Dialog {open} {onClose} {title} {description}>
|
<Dialog {open} {onClose} {title} {description}>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
|
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
|
||||||
<form method="POST" {action}>
|
<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>
|
<Button type="submit" variant="danger">{confirmLabel}</Button>
|
||||||
</form>
|
</form>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
type?: 'button' | 'submit';
|
type?: 'button' | 'submit';
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||||
shape?: 'square' | 'round';
|
shape?: 'square' | 'round';
|
||||||
tone?: 'muted' | 'subtle' | 'plain';
|
plain?: boolean;
|
||||||
pressed?: boolean;
|
pressed?: boolean;
|
||||||
surface?: boolean;
|
surface?: boolean;
|
||||||
revealOnHover?: boolean;
|
revealOnHover?: boolean;
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
type = 'button',
|
type = 'button',
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
shape = 'square',
|
shape = 'square',
|
||||||
tone = 'muted',
|
plain = false,
|
||||||
pressed,
|
pressed,
|
||||||
surface = false,
|
surface = false,
|
||||||
revealOnHover = false,
|
revealOnHover = false,
|
||||||
@@ -40,18 +40,12 @@
|
|||||||
lg: 'size-10'
|
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(
|
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(
|
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' : ''
|
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
|
||||||
} ${klass}`
|
} ${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 ===
|
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
|
||||||
option.value
|
option.value
|
||||||
? 'text-ink'
|
? 'text-ink'
|
||||||
: 'text-fg-2 hover:text-fg-2'}"
|
: 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
export const EXPLORE_PAGE_SIZE = 27;
|
export const EXPLORE_PAGE_SIZE = 27;
|
||||||
|
export const MAX_PAGE_SIZE = 100;
|
||||||
export const SEARCH_DEBOUNCE_MS = 450;
|
export const SEARCH_DEBOUNCE_MS = 450;
|
||||||
export const SEARCH_MIN_LENGTH = 2;
|
export const SEARCH_MIN_LENGTH = 2;
|
||||||
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
|
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
|
||||||
export const ALBUMS_PAGE_SIZE = 50;
|
export const ALBUMS_PAGE_SIZE = 50;
|
||||||
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
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_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_PAGE_SIZE = 10;
|
||||||
export const EXPLORE_ALBUMS_LIMIT = 18;
|
export const EXPLORE_ALBUMS_LIMIT = 18;
|
||||||
export const HOME_LATEST_PAGE_SIZE = 10;
|
|
||||||
export const HOME_MIXES_LIMIT = 6;
|
export const HOME_MIXES_LIMIT = 6;
|
||||||
export const HOME_SHELF_LIMIT = 6;
|
export const HOME_SHELF_LIMIT = 6;
|
||||||
export const HOME_CONTINUE_LIMIT = 10;
|
export const HOME_CONTINUE_LIMIT = 10;
|
||||||
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
||||||
export const HOME_POPULAR_MAX = 8;
|
|
||||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||||
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
||||||
export const EXPLORE_USERS_LIMIT = 12;
|
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[]> {
|
async function fetchTracks(url: string): Promise<Track[]> {
|
||||||
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
const res = await fetch(url);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
return ((await res.json()) as Paged<Track>).items;
|
||||||
return toQueueItems(data.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 seen = new Set<string>();
|
||||||
const merged: QueueItem[] = [];
|
const merged: Track[] = [];
|
||||||
|
|
||||||
for (const t of player.recentlyPlayed) {
|
for (const track of [...player.recentlyPlayed, ...history]) {
|
||||||
const id = String(t.id);
|
if (seen.has(track.id)) continue;
|
||||||
if (seen.has(id)) continue;
|
seen.add(track.id);
|
||||||
seen.add(id);
|
merged.push(track);
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged.slice(0, limit);
|
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';
|
import type { LikedTrack, Track } from '$lib/types';
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
class LikedStore {
|
class LikedStore {
|
||||||
entries = $state<Record<string, LikedTrack>>({});
|
entries = $state<Record<string, LikedTrack>>({});
|
||||||
@@ -27,35 +7,23 @@ class LikedStore {
|
|||||||
count = $derived(Object.keys(this.entries).length);
|
count = $derived(Object.keys(this.entries).length);
|
||||||
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
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;
|
if (this.hydrated) return;
|
||||||
this.hydrated = true;
|
this.hydrated = true;
|
||||||
|
this.entries = Object.fromEntries(tracks.map((track) => [track.id, track]));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLiked(id: string | number): boolean {
|
isLiked(id: string): boolean {
|
||||||
return String(id) in this.entries;
|
return id in this.entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggle(track: LikeToggleInput) {
|
async toggle(track: Track) {
|
||||||
const id = String(track.id);
|
const id = track.id;
|
||||||
const before = this.entries;
|
const before = this.entries;
|
||||||
const wasLiked = id in before;
|
const wasLiked = id in before;
|
||||||
const next = { ...before };
|
const next = { ...before };
|
||||||
if (wasLiked) delete next[id];
|
if (wasLiked) delete next[id];
|
||||||
else next[id] = { ...track, id, likedAt: Date.now() };
|
else next[id] = { ...track, likedAt: Date.now() };
|
||||||
this.entries = next;
|
this.entries = next;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -66,7 +34,10 @@ class LikedStore {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to toggle like');
|
if (!res.ok) throw new Error('Failed to toggle like');
|
||||||
} catch {
|
} 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 { browser } from '$app/environment';
|
||||||
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
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 { shuffle } from '$lib/data/collections';
|
||||||
import { dialog } from '$lib/state/dialog.svelte';
|
import { dialog } from '$lib/state/dialog.svelte';
|
||||||
import { ListenTracker } from './listenTracker';
|
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 {
|
export function isQueueCurrent(items: { id: string }[]): boolean {
|
||||||
id: string | number;
|
return items.some((item) => item.id === player.currentId);
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
duration: number;
|
|
||||||
explicit?: boolean;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
listensCount?: number | string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Playlist {
|
export function playAllOrToggle(items: Track[]) {
|
||||||
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[]) {
|
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
if (isQueueCurrent(items)) player.toggle();
|
if (isQueueCurrent(items)) player.toggle();
|
||||||
else player.playQueue(items, 0);
|
else player.playQueue(items, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function playShuffled(items: QueueItem[]) {
|
export function playShuffled(items: Track[]) {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
player.playQueue(shuffle(items), 0);
|
player.playQueue(shuffle(items), 0);
|
||||||
}
|
}
|
||||||
@@ -94,36 +33,30 @@ async function readErrorMessage(res: Response): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RECENT_LIMIT = 15;
|
const RECENT_LIMIT = 15;
|
||||||
|
const DEFAULT_VOLUME = 100;
|
||||||
|
const VOLUME_KEY = 'musify.player.volume';
|
||||||
|
const MUTED_KEY = 'musify.player.muted';
|
||||||
|
|
||||||
const EMPTY: PlayerTrack = {
|
function clampVolume(value: number): number {
|
||||||
id: '',
|
return Math.min(100, Math.max(0, Math.round(value)));
|
||||||
title: '',
|
}
|
||||||
artist: '',
|
|
||||||
duration: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
function toTrack(item: QueueItem): PlayerTrack {
|
function loadVolume(): number {
|
||||||
return {
|
const stored = readStorage(VOLUME_KEY);
|
||||||
id: item.id,
|
if (stored === null || stored.trim() === '') return DEFAULT_VOLUME;
|
||||||
title: item.title,
|
const parsed = Number(stored);
|
||||||
artist: item.artist ?? '',
|
return Number.isFinite(parsed) ? clampVolume(parsed) : DEFAULT_VOLUME;
|
||||||
duration: Number(item.duration) || 0,
|
|
||||||
explicit: item.explicit,
|
|
||||||
ownerUserId: item.ownerUserId,
|
|
||||||
listensCount: item.listensCount
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlayerState {
|
class PlayerState {
|
||||||
tracks = $state<PlayerTrack[]>([]);
|
tracks = $state<Track[]>([]);
|
||||||
currentId = $state<string | number | null>(null);
|
currentId = $state<string | null>(null);
|
||||||
playing = $state(false);
|
playing = $state(false);
|
||||||
progress = $state(0);
|
progress = $state(0);
|
||||||
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
|
volume = $state(browser ? loadVolume() : DEFAULT_VOLUME);
|
||||||
muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false);
|
muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false);
|
||||||
loading = $state(false);
|
loading = $state(false);
|
||||||
recentlyPlayed = $state<PlayerTrack[]>([]);
|
recentlyPlayed = $state<Track[]>([]);
|
||||||
playlists = $state<Playlist[]>([]);
|
|
||||||
shuffle = $state(false);
|
shuffle = $state(false);
|
||||||
repeat = $state(false);
|
repeat = $state(false);
|
||||||
|
|
||||||
@@ -131,20 +64,17 @@ class PlayerState {
|
|||||||
|
|
||||||
#audio: HTMLAudioElement | null = null;
|
#audio: HTMLAudioElement | null = null;
|
||||||
#loadToken = 0;
|
#loadToken = 0;
|
||||||
#accentCache = new Map<string, Accent>();
|
#accentCache = new Map<string, string>();
|
||||||
#rafId: number | null = null;
|
#rafId: number | null = null;
|
||||||
#listen = new ListenTracker();
|
#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);
|
accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
|
||||||
|
duration = $derived(this.current?.duration ?? 0);
|
||||||
progressPercent = $derived(
|
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 {
|
#audioEl(): HTMLAudioElement | null {
|
||||||
if (!browser) return null;
|
if (!browser) return null;
|
||||||
if (this.#audio) return this.#audio;
|
if (this.#audio) return this.#audio;
|
||||||
@@ -182,6 +112,10 @@ class PlayerState {
|
|||||||
audio.play().catch(() => {});
|
audio.play().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!this.shuffle && isLastIndex(this.tracks.length, this.#index())) {
|
||||||
|
this.#stopAtEnd(audio);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.next();
|
this.next();
|
||||||
});
|
});
|
||||||
audio.addEventListener('error', () => {
|
audio.addEventListener('error', () => {
|
||||||
@@ -199,6 +133,13 @@ class PlayerState {
|
|||||||
return audio;
|
return audio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#stopAtEnd(audio: HTMLAudioElement) {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
this.progress = 0;
|
||||||
|
this.playing = false;
|
||||||
|
this.#syncMediaSessionPlaybackState();
|
||||||
|
}
|
||||||
|
|
||||||
#setupMediaSession(audio: HTMLAudioElement) {
|
#setupMediaSession(audio: HTMLAudioElement) {
|
||||||
if (!('mediaSession' in navigator)) return;
|
if (!('mediaSession' in navigator)) return;
|
||||||
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
|
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
|
||||||
@@ -212,15 +153,11 @@ class PlayerState {
|
|||||||
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
|
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
|
||||||
}
|
}
|
||||||
|
|
||||||
#syncMediaSessionMetadata(track: PlayerTrack) {
|
#syncMediaSessionMetadata(track: Track) {
|
||||||
if (!browser || !('mediaSession' in navigator)) return;
|
if (!browser || !('mediaSession' in navigator)) return;
|
||||||
if (!track.id) {
|
|
||||||
navigator.mediaSession.metadata = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
title: track.title,
|
title: track.title,
|
||||||
artist: track.artist,
|
artist: track.artist ?? '',
|
||||||
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
|
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);
|
return this.tracks.findIndex((t) => t.id === this.currentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
#pushRecent(track: PlayerTrack) {
|
#pushRecent(track: Track) {
|
||||||
if (!track.id) return;
|
|
||||||
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
|
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
|
||||||
0,
|
0,
|
||||||
RECENT_LIMIT
|
RECENT_LIMIT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #applyAccent(id: string | number) {
|
async #applyAccent(id: string) {
|
||||||
const key = String(id);
|
const cached = this.#accentCache.get(id);
|
||||||
const cached = this.#accentCache.get(key);
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
this.accentColor = cached.accent;
|
this.accentColor = cached;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
|
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
|
||||||
if (this.currentId !== id) return;
|
if (this.currentId !== id) return;
|
||||||
if (result) {
|
if (result) {
|
||||||
this.#accentCache.set(key, result);
|
this.#accentCache.set(id, result);
|
||||||
this.accentColor = result.accent;
|
this.accentColor = result;
|
||||||
} else {
|
} else {
|
||||||
this.accentColor = null;
|
this.accentColor = null;
|
||||||
}
|
}
|
||||||
@@ -306,18 +241,14 @@ class PlayerState {
|
|||||||
this.#listen.begin(listenId);
|
this.#listen.begin(listenId);
|
||||||
await audio.play();
|
await audio.play();
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.#pushRecent(this.current);
|
if (track) this.#pushRecent(track);
|
||||||
} catch (exception) {
|
} catch (err) {
|
||||||
console.error('playback failed', exception);
|
console.error('playback failed', err);
|
||||||
if (token !== this.#loadToken) return;
|
if (token !== this.#loadToken) return;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
if (exception instanceof DOMException && exception.name === 'AbortError') return;
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
this.#fail(
|
this.#fail(err instanceof Error && !(err instanceof DOMException) ? err.message : undefined);
|
||||||
exception instanceof Error && !(exception instanceof DOMException)
|
|
||||||
? exception.message
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,79 +290,44 @@ class PlayerState {
|
|||||||
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
|
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
hydrate(item: ApiTrackLike) {
|
hydrate(track: Track) {
|
||||||
if (this.currentId !== null || this.tracks.length > 0) return;
|
if (this.currentId !== null || this.tracks.length > 0) return;
|
||||||
const track = toTrack(toQueueItem(item));
|
|
||||||
this.tracks = [track];
|
this.tracks = [track];
|
||||||
this.currentId = track.id;
|
this.currentId = track.id;
|
||||||
this.#applyAccent(track.id);
|
this.#applyAccent(track.id);
|
||||||
this.#syncMediaSessionMetadata(track);
|
this.#syncMediaSessionMetadata(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
|
playQueue(list: Track[], startIndex = 0) {
|
||||||
return ids
|
|
||||||
.map((id) => this.tracks.find((t) => t.id === id))
|
|
||||||
.filter((t): t is PlayerTrack => Boolean(t));
|
|
||||||
}
|
|
||||||
|
|
||||||
playQueue(list: QueueItem[], startIndex = 0) {
|
|
||||||
if (list.length === 0) return;
|
if (list.length === 0) return;
|
||||||
this.tracks = list.map(toTrack);
|
this.tracks = [...list];
|
||||||
const start = this.tracks[startIndex] ?? this.tracks[0];
|
const start = this.tracks[startIndex] ?? this.tracks[0];
|
||||||
this.currentId = start.id;
|
this.currentId = start.id;
|
||||||
this.#loadCurrent();
|
this.#loadCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
playOrToggle(list: QueueItem[], index: number) {
|
playOrToggle(list: Track[], index: number) {
|
||||||
if (index < 0 || index >= list.length) return;
|
if (index < 0 || index >= list.length) return;
|
||||||
if (this.currentId === list[index].id) this.toggle();
|
if (this.currentId === list[index].id) this.toggle();
|
||||||
else this.playQueue(list, index);
|
else this.playQueue(list, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
playNextItem(item: QueueItem) {
|
playNext(items: Track[]) {
|
||||||
this.playNext([item]);
|
|
||||||
}
|
|
||||||
|
|
||||||
playNext(items: QueueItem[]) {
|
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
if (this.currentId === null) {
|
if (this.currentId === null) {
|
||||||
this.playQueue(items, 0);
|
this.playQueue(items, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tracks = [...this.tracks];
|
this.tracks = insertNext(this.tracks, this.#index(), items);
|
||||||
tracks.splice(this.#index() + 1, 0, ...items.map(toTrack));
|
|
||||||
this.tracks = tracks;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appendToQueue(items: QueueItem[]) {
|
appendToQueue(items: Track[]) {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
if (this.currentId === null) {
|
if (this.currentId === null) {
|
||||||
this.playQueue(items, 0);
|
this.playQueue(items, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.tracks = [...this.tracks, ...items.map(toTrack)];
|
this.tracks = append(this.tracks, items);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
@@ -454,19 +350,13 @@ class PlayerState {
|
|||||||
this.muted = value;
|
this.muted = value;
|
||||||
const audio = this.#audioEl();
|
const audio = this.#audioEl();
|
||||||
if (audio) audio.muted = value;
|
if (audio) audio.muted = value;
|
||||||
if (browser) localStorage.setItem('player.muted', String(value));
|
if (browser) writeStorage(MUTED_KEY, String(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
if (this.tracks.length === 0) return;
|
if (this.tracks.length === 0) return;
|
||||||
const idx = this.#index();
|
const idx = this.#index();
|
||||||
let nextIdx = (idx + 1) % this.tracks.length;
|
this.currentId = this.tracks[nextIndex(this.tracks.length, idx, this.shuffle)].id;
|
||||||
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.#loadCurrent();
|
this.#loadCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,17 +380,11 @@ class PlayerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
moveQueueItem(from: number, insertAt: number) {
|
moveQueueItem(from: number, insertAt: number) {
|
||||||
if (from < 0 || from >= this.tracks.length) return;
|
this.tracks = move(this.tracks, from, insertAt);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearUpcoming() {
|
clearUpcoming() {
|
||||||
this.tracks = this.currentId === null ? [] : [this.current];
|
this.tracks = this.current ? [this.current] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
previous() {
|
previous() {
|
||||||
@@ -511,26 +395,24 @@ class PlayerState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const idx = this.#index();
|
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();
|
this.#loadCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
seekFraction(fraction: number) {
|
seekFraction(fraction: number) {
|
||||||
const audio = this.#audioEl();
|
const audio = this.#audioEl();
|
||||||
const clamped = Math.min(1, Math.max(0, fraction));
|
const clamped = Math.min(1, Math.max(0, fraction));
|
||||||
const target = clamped * this.current.duration;
|
const target = clamped * this.duration;
|
||||||
this.progress = target;
|
this.progress = target;
|
||||||
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
|
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(value: number) {
|
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);
|
if (this.muted && this.volume > 0) this.#applyMuted(false);
|
||||||
const audio = this.#audioEl();
|
const audio = this.#audioEl();
|
||||||
if (audio) {
|
if (audio) audio.volume = this.volume / 100;
|
||||||
audio.volume = this.volume / 100;
|
if (browser) writeStorage(VOLUME_KEY, String(this.volume));
|
||||||
localStorage.setItem('player.volume', 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 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 { paths } from '$lib/api/schema';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import { apiConfig } from '$lib/server/config';
|
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 {
|
export function requireUser(locals: App.Locals, url: URL): SessionUser {
|
||||||
if (!locals.user)
|
if (!locals.user) loginRedirect(url);
|
||||||
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
|
||||||
return locals.user;
|
return locals.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,16 +38,42 @@ export function optionalUser(
|
|||||||
allowAnonymousListening: boolean
|
allowAnonymousListening: boolean
|
||||||
): SessionUser | null {
|
): SessionUser | null {
|
||||||
if (locals.user) return locals.user;
|
if (locals.user) return locals.user;
|
||||||
if (!allowAnonymousListening)
|
if (!allowAnonymousListening) loginRedirect(url);
|
||||||
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireAccessTokenAction(
|
export function formString(form: FormData, key: string): string {
|
||||||
locals: App.Locals,
|
return String(form.get(key) ?? '');
|
||||||
message = 'Inicia sesión.'
|
}
|
||||||
): string | ActionFailure<{ message: string }> {
|
|
||||||
return locals.accessToken ?? fail(401, { message });
|
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>(
|
export function unwrapOrError<T>(
|
||||||
@@ -70,9 +105,18 @@ export function apiErrorDetail(error: unknown): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unwrapOrFail(
|
type Failure = ActionFailure<{ message: string; detail?: string }>;
|
||||||
result: { error?: unknown },
|
|
||||||
message: string
|
export function failOnError(result: { error?: unknown }, message: string): Failure | undefined {
|
||||||
): ActionFailure<{ message: string; detail?: string }> | undefined {
|
|
||||||
return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : 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;
|
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;
|
let configPromise: Promise<client.Configuration> | undefined;
|
||||||
export function getOidcConfig(): Promise<client.Configuration> {
|
export function getOidcConfig(): Promise<client.Configuration> {
|
||||||
configPromise ??= (async () => {
|
configPromise ??= (async () => {
|
||||||
@@ -29,9 +36,9 @@ export function getOidcConfig(): Promise<client.Configuration> {
|
|||||||
return secret
|
return secret
|
||||||
? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
|
? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
|
||||||
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
|
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
|
||||||
})().catch((exception) => {
|
})().catch((err) => {
|
||||||
configPromise = undefined;
|
configPromise = undefined;
|
||||||
throw exception;
|
throw err;
|
||||||
});
|
});
|
||||||
return configPromise;
|
return configPromise;
|
||||||
}
|
}
|
||||||
@@ -44,7 +51,7 @@ function sessionKey(): Promise<Uint8Array> {
|
|||||||
return keyPromise;
|
return keyPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionCookieOptions(url: URL) {
|
function sessionCookieOptions(url: URL) {
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
@@ -144,8 +151,8 @@ export async function refreshSession(session: Session): Promise<Session | null>
|
|||||||
idToken: tokens.id_token ?? session.idToken,
|
idToken: tokens.id_token ?? session.idToken,
|
||||||
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
|
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
|
||||||
};
|
};
|
||||||
} catch (exception) {
|
} catch (err) {
|
||||||
console.error('Token refresh failed', exception);
|
console.error('Token refresh failed', err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
const DAY_SECONDS = 60 * 60 * 24;
|
const DAY_SECONDS = 60 * 60 * 24;
|
||||||
|
const MIN_SESSION_SECRET_LENGTH = 32;
|
||||||
const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access';
|
const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access';
|
||||||
|
|
||||||
function positiveInt(value: string | undefined, fallback: number): number {
|
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);
|
return required('AUTH_POST_LOGOUT_URI', env.AUTH_POST_LOGOUT_URI);
|
||||||
},
|
},
|
||||||
get sessionSecret() {
|
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() {
|
get sessionTtlSeconds() {
|
||||||
return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS);
|
return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS);
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
import { fail, type RequestEvent } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
|
import { authedAction } from '$lib/server/api';
|
||||||
|
|
||||||
async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) {
|
const setFollowing = (follow: boolean) =>
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
authedAction(async ({ api, params }) => {
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
const id = params.id ?? '';
|
||||||
|
if (!id) return fail(400, { message: 'Falta el usuario.' });
|
||||||
|
|
||||||
const form = await request.formData();
|
const options = { params: { path: { id } } };
|
||||||
const id = String(form.get('userId') ?? '') || String(params.id ?? '');
|
const { error } = follow
|
||||||
if (!id) return fail(400, { message: 'Falta el usuario.' });
|
? await api.POST('/users/{id}/follow', options)
|
||||||
|
: await api.DELETE('/users/{id}/follow', options);
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
if (error) {
|
||||||
const options = { params: { path: { id } } };
|
return fail(502, {
|
||||||
const { error } = follow
|
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
|
||||||
? await api.POST('/users/{id}/follow', options)
|
});
|
||||||
: await api.DELETE('/users/{id}/follow', options);
|
}
|
||||||
|
|
||||||
if (error) {
|
return { following: follow, userId: id };
|
||||||
return fail(502, {
|
});
|
||||||
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { following: follow, userId: id };
|
export const followUserAction = setFollowing(true);
|
||||||
}
|
export const unfollowUserAction = setFollowing(false);
|
||||||
|
|
||||||
export const followUserAction = (event: RequestEvent) => setFollowing(event, true);
|
|
||||||
export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, 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';
|
import type { FollowList } from '$lib/types';
|
||||||
|
|
||||||
type ApiClient = ReturnType<typeof createApiClient>;
|
|
||||||
|
|
||||||
export function fetchFollowList(
|
export function fetchFollowList(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
list: FollowList,
|
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 {
|
interface AlbumFormBody {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,17 +8,19 @@ interface AlbumFormBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
|
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
|
||||||
const title = String(form.get('title') ?? '').trim();
|
const title = formString(form, 'title').trim();
|
||||||
if (title === '' || title.length > 200) {
|
if (title === '' || title.length > LIMITS.albumTitle) {
|
||||||
return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' };
|
return { failMessage: `El título es obligatorio (máx. ${LIMITS.albumTitle} caracteres).` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = String(form.get('description') ?? '').trim();
|
const description = formString(form, 'description').trim();
|
||||||
if (description.length > 256) {
|
if (description.length > LIMITS.albumDescription) {
|
||||||
return { failMessage: 'La descripción no puede pasar de 256 caracteres.' };
|
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;
|
const latestYear = new Date().getFullYear() + 1;
|
||||||
let releaseYear: number | null = null;
|
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> {
|
): Promise<Response> {
|
||||||
const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium';
|
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) {
|
if (!upstream.ok) {
|
||||||
return new Response(null, { status: upstream.status });
|
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 {
|
try {
|
||||||
const api = createApiClient({ fetch: fetchFn });
|
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;
|
const allowAnonymousListening = data?.allowAnonymousListening ?? false;
|
||||||
|
|
||||||
cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS };
|
cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
import { fail, type RequestEvent } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
|
import { authedAction, formString } from '$lib/server/api';
|
||||||
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
|
export const addTrackAction = authedAction(async ({ api, form, params }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
const trackId = formString(form, 'trackId');
|
||||||
|
|
||||||
const form = await request.formData();
|
|
||||||
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
|
|
||||||
const trackId = String(form.get('trackId') ?? '');
|
|
||||||
|
|
||||||
if (!playlistId || !trackId) {
|
if (!playlistId || !trackId) {
|
||||||
return fail(400, { message: 'Faltan datos de la canción.' });
|
return fail(400, { message: 'Faltan datos de la canción.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
const { error, response } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||||
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
|
|
||||||
params: { path: { playlistId } },
|
params: { path: { playlistId } },
|
||||||
body: { trackId }
|
body: { trackId }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (err) {
|
if (error) {
|
||||||
if (response?.status === 409) {
|
if (response?.status === 409) {
|
||||||
return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
|
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 };
|
return { added: true, trackId, playlistId };
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
|
export const addAlbumToPlaylistAction = authedAction(async ({ api, form, params }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
const albumId = formString(form, 'albumId');
|
||||||
|
|
||||||
const form = await request.formData();
|
|
||||||
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
|
|
||||||
const albumId = String(form.get('albumId') ?? '');
|
|
||||||
|
|
||||||
if (!playlistId || !albumId) {
|
if (!playlistId || !albumId) {
|
||||||
return fail(400, { message: 'Faltan datos del álbum.' });
|
return fail(400, { message: 'Faltan datos del álbum.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
const { data: tracks, error: tracksError } = await api.GET('/albums/{albumId}/tracks', {
|
||||||
const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', {
|
|
||||||
params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } }
|
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 });
|
return fail(502, { message: 'No se pudo cargar el álbum.', albumId });
|
||||||
}
|
}
|
||||||
|
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const track of tracks.items) {
|
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 } },
|
params: { path: { playlistId } },
|
||||||
body: { trackId: String(track.id) }
|
body: { trackId: track.id }
|
||||||
});
|
});
|
||||||
if (!err) added++;
|
if (!error) added++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { addedAlbum: true, albumId, playlistId, 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 type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
|
||||||
|
|
||||||
export async function uploadPresignedImage(
|
export async function uploadPresignedImage(
|
||||||
@@ -109,3 +111,18 @@ export async function uploadPresignedImage(
|
|||||||
|
|
||||||
return { intentId: reserved.intentId };
|
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,
|
contextMenuPosition,
|
||||||
type MenuPosition
|
type MenuPosition
|
||||||
} from '$lib/components/ui/overlay/ContextMenu.svelte';
|
} 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() {
|
export function createTrackMenu() {
|
||||||
let state = $state<TrackMenuState | null>(null);
|
let state = $state<TrackMenuState | null>(null);
|
||||||
@@ -13,7 +14,7 @@ export function createTrackMenu() {
|
|||||||
get state() {
|
get state() {
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
open(event: MouseEvent, track: ApiTrackLike) {
|
open(event: MouseEvent, track: Track) {
|
||||||
state = { track, ...contextMenuPosition(event) };
|
state = { track, ...contextMenuPosition(event) };
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
@@ -21,7 +22,7 @@ export function createTrackMenu() {
|
|||||||
},
|
},
|
||||||
playNext() {
|
playNext() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
player.playNextItem(toQueueItem(state.track));
|
player.playNext([state.track]);
|
||||||
state = null;
|
state = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { readStorage, writeStorage } from '$lib/utils/storage';
|
||||||
|
|
||||||
export type ThemeName = 'dark' | 'light';
|
export type ThemeName = 'dark' | 'light';
|
||||||
|
|
||||||
@@ -6,11 +7,7 @@ const STORAGE_KEY = 'musify.theme';
|
|||||||
|
|
||||||
function loadInitial(): ThemeName {
|
function loadInitial(): ThemeName {
|
||||||
if (!browser) return 'dark';
|
if (!browser) return 'dark';
|
||||||
try {
|
return readStorage(STORAGE_KEY) === 'light' ? 'light' : 'dark';
|
||||||
return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark';
|
|
||||||
} catch {
|
|
||||||
return 'dark';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme: ThemeName) {
|
function applyTheme(theme: ThemeName) {
|
||||||
@@ -29,11 +26,7 @@ class ThemeModeStore {
|
|||||||
this.current = this.current === 'dark' ? 'light' : 'dark';
|
this.current = this.current === 'dark' ? 'light' : 'dark';
|
||||||
applyTheme(this.current);
|
applyTheme(this.current);
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
try {
|
writeStorage(STORAGE_KEY, this.current);
|
||||||
localStorage.setItem(STORAGE_KEY, this.current);
|
|
||||||
} catch {
|
|
||||||
/* localStorage unavailable (private mode, quota) — preference just won't persist */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { ACCENT_LIGHTNESS } from './color';
|
import { ACCENT_LIGHTNESS } from './color';
|
||||||
|
|
||||||
export interface Accent {
|
|
||||||
accent: string;
|
|
||||||
gradient: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function srgbToLinear(channel: number): number {
|
function srgbToLinear(channel: number): number {
|
||||||
const c = channel / 255;
|
const c = channel / 255;
|
||||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
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;
|
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;
|
if (!browser) return null;
|
||||||
|
|
||||||
let img: HTMLImageElement;
|
let img: HTMLImageElement;
|
||||||
@@ -104,11 +99,6 @@ export async function extractAccent(url: string): Promise<Accent | null> {
|
|||||||
|
|
||||||
const h = hue.toFixed(1);
|
const h = hue.toFixed(1);
|
||||||
const c = chroma.toFixed(3);
|
const c = chroma.toFixed(3);
|
||||||
const c2 = (chroma * 0.85).toFixed(3);
|
|
||||||
const h2 = ((hue + 25) % 360).toFixed(1);
|
|
||||||
|
|
||||||
return {
|
return `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`;
|
||||||
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
|
|
||||||
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,56 @@ export interface SessionUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FollowList = 'followers' | 'following';
|
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}`;
|
return `${n} ${n === 1 ? one : many}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmtPlays(count: number | string | undefined): string {
|
export function fmtPlays(count: number): string {
|
||||||
const value = Math.max(0, Math.floor(Number(count ?? 0)));
|
const value = Math.max(0, Math.floor(count));
|
||||||
return plural(value, 'reproducción', 'reproducciones');
|
return plural(value, 'reproducción', 'reproducciones');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function albumMeta(releaseYear?: number, trackCount?: number): string {
|
export function albumMeta(releaseYear?: number | null, trackCount?: number): string {
|
||||||
return [
|
return [
|
||||||
releaseYear ? String(releaseYear) : undefined,
|
releaseYear ? String(releaseYear) : undefined,
|
||||||
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
|
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
|
||||||
@@ -36,6 +36,12 @@ export function albumMeta(releaseYear?: number, trackCount?: number): string {
|
|||||||
.join(' · ');
|
.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 {
|
export function playlistMeta(trackCount: number): string {
|
||||||
return plural(trackCount, 'canción', 'canciones');
|
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 type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import {
|
import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
|
||||||
createApiClient,
|
import { toAlbum, toPage } from '$lib/server/mappers';
|
||||||
requireAccessTokenAction,
|
|
||||||
requireUser,
|
|
||||||
unwrapOrError
|
|
||||||
} from '$lib/server/api';
|
|
||||||
import { ALBUMS_PAGE_SIZE } from '$lib/config';
|
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';
|
import { uploadPresignedImage } from '$lib/server/upload';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
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}', {
|
const result = await api.GET('/albums/users/{userId}', {
|
||||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
|
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 };
|
return { albums };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
create: async ({ request, locals, fetch }) => {
|
create: authedAction(async ({ api, form }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
|
|
||||||
const form = await request.formData();
|
|
||||||
const parsed = parseAlbumForm(form);
|
const parsed = parseAlbumForm(form);
|
||||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||||
|
|
||||||
const cover = form.get('cover');
|
const cover = formFile(form, 'cover');
|
||||||
if (!(cover instanceof File) || cover.size === 0) {
|
if (!cover) return fail(400, { message: 'La portada es obligatoria.' });
|
||||||
return fail(400, { message: 'La portada es obligatoria.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
|
||||||
|
|
||||||
const uploaded = await uploadPresignedImage(
|
const uploaded = await uploadPresignedImage(
|
||||||
(args) => api.POST('/albums/upload-picture', { body: args }),
|
(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 });
|
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 }
|
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}`);
|
redirect(303, `/albums/${data.id}`);
|
||||||
}
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,10 +40,7 @@
|
|||||||
<MediaCard
|
<MediaCard
|
||||||
href="/albums/{album.id}"
|
href="/albums/{album.id}"
|
||||||
title={album.title}
|
title={album.title}
|
||||||
subtitle={albumMeta(
|
subtitle={albumMeta(album.releaseYear, album.trackCount)}
|
||||||
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
|
||||||
Number(album.trackCount)
|
|
||||||
)}
|
|
||||||
trackIds={album.coverTrackIds}
|
trackIds={album.coverTrackIds}
|
||||||
src="/api/albums/{album.id}/cover?size=large"
|
src="/api/albums/{album.id}/cover?size=large"
|
||||||
index={i}
|
index={i}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import {
|
import {
|
||||||
createApiClient,
|
apiFor,
|
||||||
|
authedAction,
|
||||||
|
failOnError,
|
||||||
|
formFile,
|
||||||
|
formString,
|
||||||
optionalUser,
|
optionalUser,
|
||||||
requireAccessTokenAction,
|
unwrapOrError
|
||||||
unwrapOrError,
|
|
||||||
unwrapOrFail
|
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
|
import { toAlbum, toTrack } from '$lib/server/mappers';
|
||||||
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
||||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
import { parseAlbumForm } from '$lib/server/forms/albumForm';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { uploadOptionalCover } from '$lib/server/upload';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||||
const { allowAnonymousListening } = await parent();
|
const { allowAnonymousListening } = await parent();
|
||||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
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([
|
const [albumRes, tracksRes, libraryRes] = await Promise.all([
|
||||||
api.GET('/albums/{id}', { params: { path: { id: params.id } } }),
|
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)
|
: 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 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;
|
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 = {
|
export const actions: Actions = {
|
||||||
addTrack: async ({ request, params, locals, fetch }) => {
|
addTrack: authedAction(async ({ api, form, params }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
const trackId = formString(form, 'trackId');
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
|
|
||||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
|
||||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
|
||||||
const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
|
const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
|
||||||
params: { path: { albumId: params.id, trackId } }
|
params: { path: { albumId: params.id, trackId } }
|
||||||
});
|
});
|
||||||
const failure = unwrapOrFail(result, 'No se pudo añadir la canción.');
|
return failOnError(result, 'No se pudo añadir la canción.') ?? { added: true };
|
||||||
if (failure) return failure;
|
}),
|
||||||
return { added: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
removeTrack: async ({ request, params, locals, fetch }) => {
|
removeTrack: authedAction(async ({ api, form, params }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
const trackId = formString(form, 'trackId');
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
|
|
||||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
|
||||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
|
||||||
const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
|
const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
|
||||||
params: { path: { albumId: params.id, trackId } }
|
params: { path: { albumId: params.id, trackId } }
|
||||||
});
|
});
|
||||||
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
|
return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
|
||||||
if (failure) return failure;
|
}),
|
||||||
return { removed: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
edit: async ({ request, params, locals, fetch }) => {
|
edit: authedAction(async ({ api, form, params }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
|
|
||||||
const form = await request.formData();
|
|
||||||
const parsed = parseAlbumForm(form);
|
const parsed = parseAlbumForm(form);
|
||||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover'));
|
||||||
|
if ('failMessage' in cover) {
|
||||||
let newPictureIntentId: string | null = null;
|
return fail(502, { message: cover.failMessage, detail: cover.detail });
|
||||||
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 result = await api.PUT('/albums/{albumId}', {
|
const result = await api.PUT('/albums/{albumId}', {
|
||||||
@@ -107,24 +86,18 @@ export const actions: Actions = {
|
|||||||
newTitle: parsed.body.title,
|
newTitle: parsed.body.title,
|
||||||
newDescription: parsed.body.description,
|
newDescription: parsed.body.description,
|
||||||
newReleaseYear: parsed.body.releaseYear,
|
newReleaseYear: parsed.body.releaseYear,
|
||||||
newPictureIntentId
|
newPictureIntentId: cover.intentId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.');
|
return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true };
|
||||||
if (failure) return failure;
|
}),
|
||||||
return { edited: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: async ({ params, locals, fetch }) => {
|
delete: authedAction(async ({ api, params }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
|
||||||
const result = await api.DELETE('/albums/{albumId}', {
|
const result = await api.DELETE('/albums/{albumId}', {
|
||||||
params: { path: { albumId: params.id } }
|
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;
|
if (failure) return failure;
|
||||||
redirect(303, '/albums');
|
redirect(303, '/albums');
|
||||||
}
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 X from '@lucide/svelte/icons/x';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
@@ -28,15 +28,15 @@
|
|||||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
playAllOrToggle(toQueueItems(tracks));
|
playAllOrToggle(tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playTrackFrom(index: number) {
|
function playTrackFrom(index: number) {
|
||||||
player.playOrToggle(toQueueItems(tracks), index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playLibraryFrom(index: number) {
|
function playLibraryFrom(index: number) {
|
||||||
player.playOrToggle(toQueueItems(library), index);
|
player.playOrToggle(library, index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -55,10 +55,7 @@
|
|||||||
eyebrow="Álbum"
|
eyebrow="Álbum"
|
||||||
title={album.title}
|
title={album.title}
|
||||||
description={album.description ?? undefined}
|
description={album.description ?? undefined}
|
||||||
meta={albumMeta(
|
meta={albumMeta(album.releaseYear, tracks.length)}
|
||||||
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
|
||||||
tracks.length
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<Artwork
|
<Artwork
|
||||||
@@ -118,7 +115,7 @@
|
|||||||
action="?/edit"
|
action="?/edit"
|
||||||
initialTitle={album.title}
|
initialTitle={album.title}
|
||||||
initialDescription={album.description ?? ''}
|
initialDescription={album.description ?? ''}
|
||||||
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
initialReleaseYear={album.releaseYear ?? undefined}
|
||||||
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
|
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
|
||||||
formMessage={form?.message}
|
formMessage={form?.message}
|
||||||
submitLabel="Guardar"
|
submitLabel="Guardar"
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
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 { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
|
||||||
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
|
||||||
import {
|
|
||||||
EXPLORE_ALBUMS_PAGE_SIZE,
|
|
||||||
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
|
||||||
EXPLORE_USERS_LIMIT
|
|
||||||
} from '$lib/config';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
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 page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
||||||
|
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const api = apiFor({ fetch, locals });
|
||||||
|
|
||||||
const genresRes = await api.GET('/genres');
|
const genresRes = await api.GET('/genres');
|
||||||
const genres = genresRes.data ?? [];
|
const genres = genresRes.data ?? [];
|
||||||
@@ -26,17 +22,11 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
name: query || undefined,
|
name: query || undefined,
|
||||||
genre: genre ?? undefined,
|
genre: genre ?? undefined,
|
||||||
pageNumber: page,
|
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
|
const albumsPromise = query
|
||||||
? api.GET('/albums', {
|
? api.GET('/albums', {
|
||||||
params: { query: { title: query, pageNumber: 1, pageSize: EXPLORE_ALBUMS_PAGE_SIZE } }
|
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);
|
: Promise.resolve(null);
|
||||||
|
|
||||||
const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([
|
const [tracksRes, albumsRes, usersRes] = await Promise.all([
|
||||||
tracksPromise,
|
tracksPromise,
|
||||||
playlistsPromise,
|
|
||||||
albumsPromise,
|
albumsPromise,
|
||||||
usersPromise
|
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 {
|
return {
|
||||||
query,
|
query,
|
||||||
genre,
|
genre,
|
||||||
genres,
|
genres,
|
||||||
tracks,
|
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 ?? [],
|
users: usersRes?.data?.items ?? [],
|
||||||
viewerId: user?.sub ?? null,
|
usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
|
||||||
playlists: playlistsRes?.data?.items ?? []
|
viewerId: user?.sub ?? null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
addTrack: addTrackAction,
|
addTrack: addTrackAction,
|
||||||
addAlbumToPlaylist: addAlbumToPlaylistAction,
|
addAlbumToPlaylist: addAlbumToPlaylistAction
|
||||||
follow: followUserAction,
|
|
||||||
unfollow: unfollowUserAction
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player, toQueueItem } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { fetchAlbumQueueItems } from '$lib/data/albums';
|
import { fetchAlbumTracks } from '$lib/data/albums';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
|
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
@@ -49,9 +50,7 @@
|
|||||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||||
let sfilter = $state<SearchFilter>('Todo');
|
let sfilter = $state<SearchFilter>('Todo');
|
||||||
|
|
||||||
type LocalTrack = (typeof data.tracks.items)[number]['track'];
|
let localItems = $state<Track[]>([]);
|
||||||
|
|
||||||
let localItems = $state<LocalTrack[]>([]);
|
|
||||||
let localPage = $state(1);
|
let localPage = $state(1);
|
||||||
let hasMore = $state(false);
|
let hasMore = $state(false);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
@@ -60,19 +59,12 @@
|
|||||||
trackMenu.close();
|
trackMenu.close();
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
sfilter = data.genre ? 'Canciones' : 'Todo';
|
sfilter = data.genre ? 'Canciones' : 'Todo';
|
||||||
localItems = data.tracks.items.map((item) => item.track);
|
localItems = data.tracks.items;
|
||||||
localPage = Number(data.tracks.pageNumber);
|
localPage = data.tracks.pageNumber;
|
||||||
hasMore = data.tracks.hasNextPage;
|
hasMore = data.tracks.hasNextPage;
|
||||||
});
|
});
|
||||||
|
|
||||||
const songRows = $derived(
|
const items = $derived(localItems);
|
||||||
localItems.map((track) => ({
|
|
||||||
track,
|
|
||||||
seconds: Number(track.duration)
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = $derived(songRows.map((row) => row.track));
|
|
||||||
|
|
||||||
const tiles = $derived(genreTiles(data.genres));
|
const tiles = $derived(genreTiles(data.genres));
|
||||||
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
|
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
|
||||||
@@ -83,7 +75,7 @@
|
|||||||
const hasAlbums = $derived(albums.length > 0);
|
const hasAlbums = $derived(albums.length > 0);
|
||||||
const hasUsers = $derived(users.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 hasPlaylists = $derived(playlistMatches.length > 0);
|
||||||
|
|
||||||
const nothingFound = $derived(
|
const nothingFound = $derived(
|
||||||
@@ -92,10 +84,10 @@
|
|||||||
|
|
||||||
const counts = $derived(
|
const counts = $derived(
|
||||||
searchCounts({
|
searchCounts({
|
||||||
tracks: songRows.length,
|
tracks: data.tracks.totalItemCount,
|
||||||
albums: albums.length,
|
albums: data.albumsTotal,
|
||||||
playlists: playlistMatches.length,
|
playlists: playlistMatches.length,
|
||||||
users: users.length
|
users: data.usersTotal
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const chips = $derived(searchChips(counts));
|
const chips = $derived(searchChips(counts));
|
||||||
@@ -106,7 +98,7 @@
|
|||||||
type TopResult = NonNullable<
|
type TopResult = NonNullable<
|
||||||
ReturnType<
|
ReturnType<
|
||||||
typeof findTopResult<
|
typeof findTopResult<
|
||||||
LocalTrack,
|
Track,
|
||||||
(typeof albums)[number],
|
(typeof albums)[number],
|
||||||
(typeof playlistMatches)[number],
|
(typeof playlistMatches)[number],
|
||||||
(typeof users)[number]
|
(typeof users)[number]
|
||||||
@@ -128,7 +120,7 @@
|
|||||||
|
|
||||||
function playTopResult() {
|
function playTopResult() {
|
||||||
if (topResult?.kind !== 'track') return;
|
if (topResult?.kind !== 'track') return;
|
||||||
player.playOrToggle([toQueueItem(topResult.track)], 0);
|
player.playOrToggle([topResult.track], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topResultHref = $derived.by(() => {
|
const topResultHref = $derived.by(() => {
|
||||||
@@ -139,11 +131,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function togglePlay(index: number) {
|
||||||
player.playOrToggle(items.map(toQueueItem), index);
|
player.playOrToggle(items, index);
|
||||||
}
|
|
||||||
|
|
||||||
function openContextMenu(event: MouseEvent, track: LocalTrack) {
|
|
||||||
trackMenu.open(event, track);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||||
@@ -154,14 +142,14 @@
|
|||||||
if (!albumMenu) return;
|
if (!albumMenu) return;
|
||||||
const { albumId } = albumMenu;
|
const { albumId } = albumMenu;
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
player.playNext(await fetchAlbumQueueItems(albumId));
|
player.playNext(await fetchAlbumTracks(albumId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function albumAddToQueue() {
|
async function albumAddToQueue() {
|
||||||
if (!albumMenu) return;
|
if (!albumMenu) return;
|
||||||
const { albumId } = albumMenu;
|
const { albumId } = albumMenu;
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
player.appendToQueue(await fetchAlbumQueueItems(albumId));
|
player.appendToQueue(await fetchAlbumTracks(albumId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
@@ -177,12 +165,8 @@
|
|||||||
const res = await fetch(`/api/tracks?${params}`);
|
const res = await fetch(`/api/tracks?${params}`);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = (await res.json()) as typeof data.tracks;
|
const next = (await res.json()) as typeof data.tracks;
|
||||||
localItems = appendUnique(
|
localItems = appendUnique(localItems, next.items, (track) => track.id);
|
||||||
localItems,
|
localPage = next.pageNumber;
|
||||||
next.items.map((item) => item.track),
|
|
||||||
(track) => track.id
|
|
||||||
);
|
|
||||||
localPage = Number(next.pageNumber);
|
|
||||||
hasMore = next.hasNextPage;
|
hasMore = next.hasNextPage;
|
||||||
} catch {
|
} catch {
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
@@ -340,30 +324,30 @@
|
|||||||
<div class="mt-9 flex flex-col gap-9">
|
<div class="mt-9 flex flex-col gap-9">
|
||||||
{#if showGroup(sfilter, 'Canciones')}
|
{#if showGroup(sfilter, 'Canciones')}
|
||||||
<div>
|
<div>
|
||||||
{#if songRows.length > 0}
|
{#if items.length > 0}
|
||||||
<SectionHeading title="Canciones" count={counts.Canciones} />
|
<SectionHeading title="Canciones" count={counts.Canciones} />
|
||||||
<ul class="flex flex-col gap-1">
|
<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>
|
<li>
|
||||||
<ListRow
|
<ListRow
|
||||||
title={track.title}
|
title={track.title}
|
||||||
subtitle={track.artist}
|
subtitle={track.artist}
|
||||||
subtitleHref={track.ownerUserId}
|
subtitleHref={track.ownerUserId}
|
||||||
explicit={track.isExplicit}
|
explicit={track.explicit}
|
||||||
active={player.current.id === track.id}
|
active={player.currentId === track.id}
|
||||||
size="lg"
|
size="lg"
|
||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
onclick={() => togglePlay(i)}
|
onclick={() => togglePlay(i)}
|
||||||
oncontextmenu={(e) => openContextMenu(e, track)}
|
oncontextmenu={(e) => trackMenu.open(e, track)}
|
||||||
>
|
>
|
||||||
{#snippet overlay()}
|
{#snippet overlay()}
|
||||||
{#if player.current.id === track.id}
|
{#if player.currentId === track.id}
|
||||||
<EqBars overlay paused={!player.playing} />
|
<EqBars overlay paused={!player.playing} />
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
<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>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ListRow>
|
</ListRow>
|
||||||
@@ -400,7 +384,7 @@
|
|||||||
>
|
>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
<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>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ListRow>
|
</ListRow>
|
||||||
@@ -459,10 +443,10 @@
|
|||||||
]}
|
]}
|
||||||
playlistAction={{
|
playlistAction={{
|
||||||
action: '?/addTrack',
|
action: '?/addTrack',
|
||||||
fields: { trackId: String(trackMenu.state.track.id) },
|
fields: { trackId: trackMenu.state.track.id },
|
||||||
label: 'Añadir a una playlist'
|
label: 'Añadir a una playlist'
|
||||||
}}
|
}}
|
||||||
playlists={data.playlists}
|
playlists={data.userPlaylists}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -481,6 +465,6 @@
|
|||||||
fields: { albumId: albumMenu.albumId },
|
fields: { albumId: albumMenu.albumId },
|
||||||
label: 'Añadir álbum a una playlist'
|
label: 'Añadir álbum a una playlist'
|
||||||
}}
|
}}
|
||||||
playlists={data.playlists}
|
playlists={data.userPlaylists}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { toDatedTrack, toPage } from '$lib/server/mappers';
|
||||||
import {
|
import {
|
||||||
createApiClient,
|
apiFor,
|
||||||
requireAccessTokenAction,
|
authedAction,
|
||||||
|
failOnError,
|
||||||
|
formString,
|
||||||
requireUser,
|
requireUser,
|
||||||
unwrapOrError,
|
unwrapOrError
|
||||||
unwrapOrFail
|
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
|
import { LIBRARY_PAGE_SIZE } from '$lib/config';
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
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}', {
|
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 = {
|
export const actions: Actions = {
|
||||||
deleteTrack: async ({ request, locals, fetch }) => {
|
deleteTrack: authedAction(async ({ api, form }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
const trackId = formString(form, 'trackId');
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
|
||||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
|
||||||
const result = await api.DELETE('/tracks/{trackId}', {
|
const result = await api.DELETE('/tracks/{trackId}', {
|
||||||
params: { path: { trackId } }
|
params: { path: { trackId } }
|
||||||
});
|
});
|
||||||
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.');
|
return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true };
|
||||||
if (failure) return failure;
|
})
|
||||||
return { deleted: true };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,22 +2,24 @@
|
|||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
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 { plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.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';
|
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const items = $derived(data.tracks.items);
|
const items = $derived(data.tracks.items);
|
||||||
const total = $derived(Number(data.tracks.totalItemCount));
|
const total = $derived(data.tracks.totalItemCount);
|
||||||
const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt })));
|
|
||||||
|
let deleting = $state<(typeof items)[number] | null>(null);
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function togglePlay(index: number) {
|
||||||
player.playOrToggle(toQueueItems(items), index);
|
player.playOrToggle(items, index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -43,10 +45,14 @@
|
|||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<TrackList
|
<TrackList
|
||||||
tracks={listTracks}
|
tracks={items}
|
||||||
columns={['uploaded', 'plays']}
|
columns={['uploaded', 'plays']}
|
||||||
onPlay={togglePlay}
|
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}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -63,3 +69,13 @@
|
|||||||
</EmptyState>
|
</EmptyState>
|
||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</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';
|
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||||
|
|
||||||
const tracks = $derived(liked.list);
|
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));
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
columns={['added', 'plays']}
|
columns={['added', 'plays']}
|
||||||
onPlay={playFrom}
|
onPlay={playFrom}
|
||||||
rowAction={{
|
rowAction={{
|
||||||
onclick: (track) => liked.toggle({ ...track, artist: track.artist ?? undefined }),
|
onclick: (track) => liked.toggle(track),
|
||||||
icon: X,
|
icon: X,
|
||||||
label: 'Quitar de Me gusta'
|
label: 'Quitar de Me gusta'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
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 { addTrackAction } from '$lib/server/playlistActions';
|
||||||
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
requireUser(locals, url);
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const api = apiFor({ fetch, locals });
|
||||||
|
|
||||||
const [mixRes, playlistsRes] = await Promise.all([
|
const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } });
|
||||||
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 mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404);
|
return { mix: toMix(unwrapOrError(mixRes, 'Mezcla no encontrada.', 404)) };
|
||||||
|
|
||||||
return { mix, playlists: playlistsRes.data?.items ?? [] };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import {
|
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||||
player,
|
|
||||||
isQueueCurrent,
|
|
||||||
playAllOrToggle,
|
|
||||||
playShuffled,
|
|
||||||
toQueueItem
|
|
||||||
} from '$lib/player/player.svelte';
|
|
||||||
import { fmtTime, plural } from '$lib/utils/format';
|
import { fmtTime, plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
||||||
@@ -18,33 +12,19 @@
|
|||||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
import { createTrackMenu } from '$lib/state/menus.svelte';
|
import { createTrackMenu } from '$lib/state/menus.svelte';
|
||||||
import { mixItemTrack } from '$lib/data/mixes';
|
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const mix = $derived(data.mix);
|
const mix = $derived(data.mix);
|
||||||
const items = $derived(mix.items);
|
const tracks = $derived(mix.tracks);
|
||||||
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 totalSeconds = $derived(
|
const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0));
|
||||||
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
);
|
|
||||||
const isCurrentQueue = $derived(isQueueCurrent(queue));
|
|
||||||
|
|
||||||
const trackMenu = createTrackMenu();
|
const trackMenu = createTrackMenu();
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
player.playOrToggle(queue, index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -60,18 +40,18 @@
|
|||||||
eyebrow="Mezcla"
|
eyebrow="Mezcla"
|
||||||
title={mix.title}
|
title={mix.title}
|
||||||
description={mix.subtitle ?? undefined}
|
description={mix.subtitle ?? undefined}
|
||||||
meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
|
meta="{plural(tracks.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
|
||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<Artwork
|
<Artwork
|
||||||
trackIds={items.map((item) => item.trackId)}
|
trackIds={tracks.map((track) => track.id)}
|
||||||
size="hero"
|
size="hero"
|
||||||
alt={mix.title}
|
alt={mix.title}
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#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}
|
{#if isCurrentQueue && player.playing}
|
||||||
Pausar
|
Pausar
|
||||||
{:else}
|
{:else}
|
||||||
@@ -81,8 +61,8 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => playShuffled(queue)}
|
onclick={() => playShuffled(tracks)}
|
||||||
disabled={queue.length === 0}
|
disabled={tracks.length === 0}
|
||||||
>
|
>
|
||||||
<Shuffle class="size-4" />
|
<Shuffle class="size-4" />
|
||||||
Aleatorio
|
Aleatorio
|
||||||
@@ -95,7 +75,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<TrackList
|
<TrackList
|
||||||
tracks={listTracks}
|
{tracks}
|
||||||
columns={['plays']}
|
columns={['plays']}
|
||||||
onPlay={playFrom}
|
onPlay={playFrom}
|
||||||
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
|
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
|
||||||
@@ -113,9 +93,9 @@
|
|||||||
]}
|
]}
|
||||||
playlistAction={{
|
playlistAction={{
|
||||||
action: '?/addTrack',
|
action: '?/addTrack',
|
||||||
fields: { trackId: String(trackMenu.state.track.id) },
|
fields: { trackId: trackMenu.state.track.id },
|
||||||
label: 'Añadir a una playlist'
|
label: 'Añadir a una playlist'
|
||||||
}}
|
}}
|
||||||
playlists={data.playlists}
|
playlists={data.userPlaylists}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import {
|
import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
|
||||||
createApiClient,
|
import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||||
requireAccessTokenAction,
|
import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
|
||||||
requireUser,
|
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
||||||
unwrapOrError
|
import { uploadOptionalCover } from '$lib/server/upload';
|
||||||
} from '$lib/server/api';
|
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
|
||||||
|
|
||||||
const MAX_NAME = 100;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
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}', {
|
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.');
|
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) =>
|
data.items.map((playlist) =>
|
||||||
api
|
api
|
||||||
.GET('/playlists/{playlistId}/tracks', {
|
.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 items = data.items.map((playlist, i) =>
|
||||||
const tracks = tracksByPlaylist[i];
|
toPlaylistSummary(playlist, tracksByPlaylist[i].length)
|
||||||
const trackCount = tracks.length;
|
);
|
||||||
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
|
|
||||||
return { ...playlist, trackCount, durationSeconds };
|
|
||||||
});
|
|
||||||
|
|
||||||
const totals = {
|
const totals = {
|
||||||
playlistCount: items.length,
|
playlistCount: items.length,
|
||||||
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
|
trackCount: items.reduce((sum, playlist) => sum + playlist.trackCount, 0),
|
||||||
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
|
durationSeconds: tracksByPlaylist.flat().reduce((sum, track) => sum + track.duration, 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
return { playlists: { ...data, items }, totals };
|
return { playlists: items, totals };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
create: async ({ request, locals, fetch }) => {
|
create: authedAction(async ({ api, form }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
const parsed = parsePlaylistForm(form);
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
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 uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
|
||||||
const name = String(form.get('name') ?? '').trim();
|
if ('failMessage' in uploaded) {
|
||||||
const description = String(form.get('description') ?? '').trim();
|
return fail(502, {
|
||||||
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
|
message: uploaded.failMessage,
|
||||||
const cover = form.get('cover');
|
|
||||||
|
|
||||||
if (name === '' || name.length > MAX_NAME) {
|
|
||||||
return fail(400, {
|
|
||||||
message: 'El nombre es obligatorio (máx. 100 caracteres).',
|
|
||||||
name,
|
name,
|
||||||
description
|
description,
|
||||||
|
detail: uploaded.detail
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
const { data, error } = await api.POST('/playlists', {
|
||||||
|
body: { name, description, pictureIntentId: uploaded.intentId, visibility }
|
||||||
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 }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (err || !data)
|
if (error || !data)
|
||||||
return fail(502, { message: 'No se pudo crear la playlist.', name, description });
|
return fail(502, { message: 'No se pudo crear la playlist.', name, description });
|
||||||
|
|
||||||
redirect(303, `/playlists/${data.id}`);
|
redirect(303, `/playlists/${data.id}`);
|
||||||
}
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const items = $derived(data.playlists.items);
|
const items = $derived(data.playlists);
|
||||||
const totals = $derived(data.totals);
|
const totals = $derived(data.totals);
|
||||||
const summary = $derived(
|
const summary = $derived(
|
||||||
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
|
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<MediaCard
|
<MediaCard
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
subtitle={playlistMeta(Number(playlist.trackCount))}
|
subtitle={playlistMeta(playlist.trackCount)}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
playlist.updatedAt
|
playlist.updatedAt
|
||||||
|
|||||||
@@ -1,95 +1,80 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import {
|
import {
|
||||||
createApiClient,
|
apiFor,
|
||||||
|
authedAction,
|
||||||
|
failOnError,
|
||||||
|
formString,
|
||||||
optionalUser,
|
optionalUser,
|
||||||
requireAccessTokenAction,
|
unwrapOrError
|
||||||
unwrapOrError,
|
|
||||||
unwrapOrFail
|
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||||
|
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
|
||||||
const MAX_NAME = 100;
|
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
||||||
|
import { uploadOptionalCover } from '$lib/server/upload';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||||
const { allowAnonymousListening } = await parent();
|
const { allowAnonymousListening } = await parent();
|
||||||
optionalUser(locals, url, allowAnonymousListening);
|
const user = optionalUser(locals, url, allowAnonymousListening);
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const api = apiFor({ fetch, locals });
|
||||||
|
|
||||||
const [playlistRes, tracksRes] = await Promise.all([
|
const [playlistRes, tracksRes] = await Promise.all([
|
||||||
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
||||||
api.GET('/playlists/{playlistId}/tracks', {
|
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 playlist = toPlaylist(unwrapOrError(playlistRes, 'Playlist no encontrada.', 404));
|
||||||
const tracks = tracksRes.data?.items ?? [];
|
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 = {
|
export const actions: Actions = {
|
||||||
removeTrack: async ({ request, params, locals, fetch }) => {
|
removeTrack: authedAction(async ({ api, form, params }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
const trackId = formString(form, 'trackId');
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
|
||||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
|
||||||
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
|
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
|
||||||
params: { path: { playlistId: params.id, trackId } }
|
params: { path: { playlistId: params.id, trackId } }
|
||||||
});
|
});
|
||||||
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
|
return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
|
||||||
if (failure) return failure;
|
}),
|
||||||
return { removed: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
rename: async ({ request, params, locals, fetch }) => {
|
edit: authedAction(async ({ api, form, params }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
const parsed = parsePlaylistForm(form);
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||||
const form = await request.formData();
|
const { name, description, visibility, cover } = parsed.body;
|
||||||
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).' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
|
||||||
|
if ('failMessage' in uploaded) {
|
||||||
let newPictureIntentId: string | null = null;
|
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
|
||||||
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 result = await api.PUT('/playlists/{playlistId}', {
|
const result = await api.PUT('/playlists/{playlistId}', {
|
||||||
params: { path: { playlistId: params.id } },
|
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.');
|
return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true };
|
||||||
if (failure) return failure;
|
}),
|
||||||
return { renamed: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: async ({ params, locals, fetch }) => {
|
delete: authedAction(async ({ api, params }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
|
||||||
const result = await api.DELETE('/playlists/{playlistId}', {
|
const result = await api.DELETE('/playlists/{playlistId}', {
|
||||||
params: { path: { playlistId: params.id } }
|
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;
|
if (failure) return failure;
|
||||||
redirect(303, '/playlists');
|
redirect(303, '/playlists');
|
||||||
}
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import X from '@lucide/svelte/icons/x';
|
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 Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||||
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
|
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.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 EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||||
import { playlistMeta } from '$lib/utils/format';
|
import { playlistMeta } from '$lib/utils/format';
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
|
|
||||||
const playlist = $derived(data.playlist);
|
const playlist = $derived(data.playlist);
|
||||||
const tracks = $derived(data.tracks);
|
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 editing = $state(false);
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
@@ -25,11 +26,11 @@
|
|||||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
playAllOrToggle(toQueueItems(tracks));
|
playAllOrToggle(tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
player.playOrToggle(toQueueItems(tracks), index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -39,8 +40,13 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
|
<BackLink
|
||||||
|
href={isOwner ? '/playlists' : '/'}
|
||||||
|
label={isOwner ? 'Volver a tus playlists' : 'Volver al inicio'}
|
||||||
|
/>
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Lista"
|
eyebrow="Playlist"
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
description={playlist.description ?? undefined}
|
description={playlist.description ?? undefined}
|
||||||
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
|
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
|
||||||
@@ -67,19 +73,23 @@
|
|||||||
Reproducir
|
Reproducir
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
|
{#if isOwner}
|
||||||
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
|
||||||
Eliminar
|
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
||||||
</Button>
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
<TrackList
|
<TrackList
|
||||||
tracks={listTracks}
|
{tracks}
|
||||||
columns={['added', 'plays']}
|
columns={['added', 'plays']}
|
||||||
onPlay={playFrom}
|
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}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -96,7 +106,7 @@
|
|||||||
|
|
||||||
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
|
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
|
||||||
<PlaylistForm
|
<PlaylistForm
|
||||||
action="?/rename"
|
action="?/edit"
|
||||||
initialName={playlist.name}
|
initialName={playlist.name}
|
||||||
initialDescription={playlist.description ?? ''}
|
initialDescription={playlist.description ?? ''}
|
||||||
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
|
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
|
||||||
|
|||||||
@@ -1,59 +1,33 @@
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import {
|
import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api';
|
||||||
createApiClient,
|
import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm';
|
||||||
requireAccessTokenAction,
|
|
||||||
requireUser,
|
|
||||||
unwrapOrFail
|
|
||||||
} from '$lib/server/api';
|
|
||||||
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
||||||
import { findConflict, genreInfo } from '$lib/data/genres';
|
import { findConflict, genreInfo } from '$lib/data/genres';
|
||||||
import type { components } from '$lib/api/schema';
|
import type { components } from '$lib/api/schema';
|
||||||
|
|
||||||
type Genre = components['schemas']['Genre'];
|
type Genre = components['schemas']['Genre'];
|
||||||
|
|
||||||
const MAX_TITLE = 100;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
requireUser(locals, url);
|
requireUser(locals, url);
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const api = apiFor({ fetch, locals });
|
||||||
const { data } = await api.GET('/genres/available');
|
const { data } = await api.GET('/genres/available');
|
||||||
return { genres: data ?? [] };
|
return { genres: data ?? [] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ request, locals, fetch }) => {
|
default: authedAction(async ({ api, form }) => {
|
||||||
const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.');
|
const parsed = parseTrackUploadForm(form);
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
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 { data: available } = await api.GET('/genres/available');
|
||||||
const options = available ?? [];
|
const options = available ?? [];
|
||||||
const known = new Set<string>(options.map((option) => option.genre));
|
const known = new Set<string>(options.map((option) => option.genre));
|
||||||
if (!rawTags.every((tag) => known.has(tag))) {
|
if (!rawTags.every((tag) => known.has(tag))) {
|
||||||
return fail(400, { message: 'Alguno de los géneros no es válido.' });
|
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);
|
const conflict = findConflict(tags, options);
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
return fail(400, {
|
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;
|
if (urlsFailure) return urlsFailure;
|
||||||
const urls = urlsResult.data!;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [audioBuffer, coverBuffer] = await Promise.all([
|
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;
|
if (trackFailure) return trackFailure;
|
||||||
const track = trackResult.data!;
|
|
||||||
|
|
||||||
return { success: true, trackId: track.id, title: track.title };
|
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 Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
||||||
import Chip from '$lib/components/ui/primitives/Chip.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';
|
import { genreOptions } from '$lib/data/genres';
|
||||||
|
|
||||||
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
||||||
type StepState = 'idle' | 'active' | 'done';
|
type StepState = 'idle' | 'active' | 'done';
|
||||||
|
|
||||||
const MAX_TITLE = 100;
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
@@ -70,12 +70,6 @@
|
|||||||
return s === 'idle' ? '' : 'bg-accent-tint';
|
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) {
|
function pickAudio(file: File) {
|
||||||
audioName = file.name;
|
audioName = file.name;
|
||||||
audioSize = file.size;
|
audioSize = file.size;
|
||||||
@@ -256,12 +250,12 @@
|
|||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
maxlength={MAX_TITLE}
|
maxlength={LIMITS.trackTitle}
|
||||||
placeholder="Nombre de la canción"
|
placeholder="Nombre de la canción"
|
||||||
/>
|
/>
|
||||||
{#snippet hint()}
|
{#snippet hint()}
|
||||||
<p>Se usa como nombre público de la pista en el catálogo.</p>
|
<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}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -299,14 +293,9 @@
|
|||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
<Checkbox bind:checked={acceptedTerms}>
|
<Checkbox bind:checked={acceptedTerms}>
|
||||||
Acepto los
|
Acepto los términos y condiciones al subir {title.trim() !== ''
|
||||||
<a
|
? `«${title}»`
|
||||||
href="/terms"
|
: 'esta canción'}.
|
||||||
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'}.
|
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
|
||||||
{#if status === 'uploading'}
|
{#if status === 'uploading'}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
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';
|
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
||||||
|
|
||||||
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||||
const { allowAnonymousListening } = await parent();
|
const { allowAnonymousListening } = await parent();
|
||||||
const viewer = optionalUser(locals, url, allowAnonymousListening);
|
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([
|
const [profileRes, playlistsRes] = await Promise.all([
|
||||||
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
|
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) =>
|
playlistItems.map((playlist) =>
|
||||||
api
|
api
|
||||||
.GET('/playlists/{playlistId}/tracks', {
|
.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))
|
.then((res) => Number(res.data?.totalItemCount ?? 0))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const playlists = playlistItems.map((playlist, i) => ({
|
const playlists = playlistItems.map((playlist, i) =>
|
||||||
...playlist,
|
toPlaylistSummary(playlist, tracksByPlaylist[i])
|
||||||
trackCount: tracksByPlaylist[i]
|
);
|
||||||
}));
|
|
||||||
|
|
||||||
const isOwnProfile = viewer !== null && viewer.sub === params.id;
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet followStats()}
|
{#snippet followStats()}
|
||||||
{@const followers = plural(Number(profile.followersCount), 'seguidor', 'seguidores')}
|
{@const followers = plural(data.followersCount, 'seguidor', 'seguidores')}
|
||||||
{#if data.isOwnProfile || profile.canViewFollowers}
|
{#if data.isOwnProfile || profile.canViewFollowers}
|
||||||
<a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
|
<a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<MediaCard
|
<MediaCard
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
subtitle={playlistMeta(Number(playlist.trackCount))}
|
subtitle={playlistMeta(playlist.trackCount)}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
playlist.updatedAt
|
playlist.updatedAt
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
|
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||||
|
import { toPage } from '$lib/server/mappers';
|
||||||
import { fetchFollowList } from '$lib/server/follows';
|
import { fetchFollowList } from '$lib/server/follows';
|
||||||
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
|
||||||
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||||
const { allowAnonymousListening } = await parent();
|
const { allowAnonymousListening } = await parent();
|
||||||
const viewer = optionalUser(locals, url, allowAnonymousListening);
|
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([
|
const [profileRes, listRes] = await Promise.all([
|
||||||
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
|
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 {
|
return {
|
||||||
list: params.list,
|
list: params.list,
|
||||||
profile,
|
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,
|
forbidden,
|
||||||
viewerId: viewer?.sub ?? null
|
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 PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||||
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.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 UserRow from '$lib/components/ui/media/UserRow.svelte';
|
||||||
import { appendUnique } from '$lib/data/collections';
|
import { appendUnique } from '$lib/data/collections';
|
||||||
import { plural } from '$lib/utils/format';
|
import { plural } from '$lib/utils/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
type ListUser = NonNullable<typeof data.users>['items'][number];
|
type ListUser = NonNullable<typeof data.users>['items'][number];
|
||||||
|
|
||||||
@@ -21,13 +20,13 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
items = data.users?.items ?? [];
|
items = data.users?.items ?? [];
|
||||||
page = Number(data.users?.pageNumber ?? 1);
|
page = data.users?.pageNumber ?? 1;
|
||||||
hasMore = data.users?.hasNextPage ?? false;
|
hasMore = data.users?.hasNextPage ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isFollowers = $derived(data.list === 'followers');
|
const isFollowers = $derived(data.list === 'followers');
|
||||||
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
|
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
|
||||||
const total = $derived(Number(data.users?.totalItemCount ?? 0));
|
const total = $derived(data.users?.totalItemCount ?? 0);
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loadingMore) return;
|
if (loadingMore) return;
|
||||||
@@ -37,7 +36,7 @@
|
|||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = (await res.json()) as NonNullable<typeof data.users>;
|
const next = (await res.json()) as NonNullable<typeof data.users>;
|
||||||
items = appendUnique(items, next.items, (u) => u.id);
|
items = appendUnique(items, next.items, (u) => u.id);
|
||||||
page = Number(next.pageNumber);
|
page = next.pageNumber;
|
||||||
hasMore = next.hasNextPage;
|
hasMore = next.hasNextPage;
|
||||||
} catch {
|
} catch {
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
@@ -62,10 +61,6 @@
|
|||||||
: plural(total, 'usuario', 'usuarios')}
|
: plural(total, 'usuario', 'usuarios')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if form?.message}
|
|
||||||
<Alert class="mb-4">{form.message}</Alert>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.forbidden}
|
{#if data.forbidden}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Lock}
|
icon={Lock}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<p class="mb-6.5 text-body text-fg-2">
|
<p class="mb-6.5 text-body text-fg-2">
|
||||||
{page.status === 404
|
{page.status === 404
|
||||||
? 'Comprueba el enlace o vuelve al inicio.'
|
? '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>
|
</p>
|
||||||
<Button href="/" variant="secondary">Volver</Button>
|
<Button href="/" variant="secondary">Volver</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,92 +1,76 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
|
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 { 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 PUBLIC_PATHS = new Set(['/auth']);
|
||||||
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
|
||||||
|
|
||||||
async function fetchSidebarPlaylists(
|
function orFallback<T>(load: Promise<T>, fallback: T): Promise<T> {
|
||||||
fetchFn: typeof fetch,
|
return load.catch(() => fallback);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
|
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
|
||||||
|
|
||||||
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
|
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
|
||||||
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
loginRedirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountUrl = authConfig.issuer
|
const accountUrl = `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`;
|
||||||
? `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
return {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
allowAnonymousListening,
|
allowAnonymousListening,
|
||||||
sidebarPlaylists: [],
|
userPlaylists: [],
|
||||||
|
userPlaylistsTotal: 0,
|
||||||
likedTracks: [],
|
likedTracks: [],
|
||||||
lastPlayedTrack: null,
|
lastPlayedTrack: null,
|
||||||
accountUrl
|
accountUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [sidebarPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([
|
const api = apiFor({ fetch, locals });
|
||||||
fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub),
|
const userId = locals.user.sub;
|
||||||
fetchLikedTracks(fetch, locals.accessToken),
|
|
||||||
fetchLastPlayedTrack(fetch, locals.accessToken, 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 {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
allowAnonymousListening,
|
allowAnonymousListening,
|
||||||
sidebarPlaylists,
|
userPlaylists: userPlaylists.items,
|
||||||
|
userPlaylistsTotal: userPlaylists.total,
|
||||||
likedTracks,
|
likedTracks,
|
||||||
lastPlayedTrack,
|
lastPlayedTrack,
|
||||||
accountUrl
|
accountUrl
|
||||||
|
|||||||
@@ -41,10 +41,11 @@
|
|||||||
|
|
||||||
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
|
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
|
||||||
|
|
||||||
let wasPlaying = false;
|
let queueAutoOpened = false;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (player.playing && !wasPlaying) queuePanel.show();
|
if (!player.playing || queueAutoOpened) return;
|
||||||
wasPlaying = player.playing;
|
queueAutoOpened = true;
|
||||||
|
queuePanel.show();
|
||||||
});
|
});
|
||||||
|
|
||||||
function isTypingTarget(target: EventTarget | null): boolean {
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
@@ -107,7 +108,11 @@
|
|||||||
<div class="app-backdrop"></div>
|
<div class="app-backdrop"></div>
|
||||||
<div class="app-vignette"></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-w-0 flex-1 flex-col">
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
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 { addTrackAction } from '$lib/server/playlistActions';
|
||||||
import {
|
import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config';
|
||||||
HOME_LATEST_PAGE_SIZE,
|
|
||||||
HOME_MIXES_LIMIT,
|
|
||||||
HOME_SHELF_LIMIT,
|
|
||||||
HOME_SPOTLIGHT_TRACKS_LIMIT
|
|
||||||
} from '$lib/config';
|
|
||||||
import { pickGreeting } from '$lib/server/greeting';
|
import { pickGreeting } from '$lib/server/greeting';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
if (!locals.user) redirect(302, '/explore');
|
if (!locals.user) redirect(302, '/explore');
|
||||||
const user = requireUser(locals, url);
|
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}', {
|
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||||
params: {
|
params: {
|
||||||
path: { userId: user.sub },
|
path: { userId: user.sub },
|
||||||
@@ -30,17 +23,19 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
});
|
});
|
||||||
const listeningStatsPromise = api
|
const listeningStatsPromise = api
|
||||||
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
|
.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 }));
|
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
|
||||||
|
|
||||||
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
|
const [playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||||
latestPromise,
|
|
||||||
playlistsPromise,
|
playlistsPromise,
|
||||||
mixesPromise,
|
mixesPromise,
|
||||||
recentlyPlayedPromise
|
recentlyPlayedPromise
|
||||||
]);
|
]);
|
||||||
const playlistItems = playlists.data?.items ?? [];
|
const playlistItems = (playlists.data?.items ?? []).map(toPlaylist);
|
||||||
const latestItems = (latest.data?.items ?? []).map((item) => item.track);
|
|
||||||
|
|
||||||
const spotlightPlaylist = playlistItems[0] ?? null;
|
const spotlightPlaylist = playlistItems[0] ?? null;
|
||||||
const spotlightTracksPromise = spotlightPlaylist
|
const spotlightTracksPromise = spotlightPlaylist
|
||||||
@@ -52,7 +47,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((res) => ({
|
.then((res) => ({
|
||||||
items: res.data?.items ?? [],
|
items: (res.data?.items ?? []).map(toTrack),
|
||||||
totalCount: Number(res.data?.totalItemCount ?? 0)
|
totalCount: Number(res.data?.totalItemCount ?? 0)
|
||||||
}))
|
}))
|
||||||
.catch(() => ({ items: [], totalCount: 0 }))
|
.catch(() => ({ items: [], totalCount: 0 }))
|
||||||
@@ -60,11 +55,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
greeting: pickGreeting(new Date()),
|
greeting: pickGreeting(new Date()),
|
||||||
recentlyPlayed: recentlyPlayed.data ?? [],
|
recentlyPlayed: (recentlyPlayed.data ?? []).map(toTrack),
|
||||||
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT),
|
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT).map(toMix),
|
||||||
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
|
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
|
||||||
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
|
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
|
||||||
newReleases: latestItems,
|
|
||||||
spotlightPlaylist,
|
spotlightPlaylist,
|
||||||
spotlightTracks: spotlightTracksPromise,
|
spotlightTracks: spotlightTracksPromise,
|
||||||
listeningStats: listeningStatsPromise
|
listeningStats: listeningStatsPromise
|
||||||
|
|||||||
@@ -3,23 +3,14 @@
|
|||||||
import History from '@lucide/svelte/icons/history';
|
import History from '@lucide/svelte/icons/history';
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import {
|
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||||
player,
|
import type { Track } from '$lib/types';
|
||||||
isQueueCurrent,
|
|
||||||
playAllOrToggle,
|
|
||||||
playShuffled,
|
|
||||||
toQueueItem,
|
|
||||||
trackFromQueueItem,
|
|
||||||
type ApiTrackLike
|
|
||||||
} from '$lib/player/player.svelte';
|
|
||||||
import { pressable } from '$lib/actions/pressable';
|
|
||||||
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
||||||
import { mixItemTrack } from '$lib/data/mixes';
|
import { fetchPlaylistTracks } from '$lib/data/albums';
|
||||||
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/utils/format';
|
import { fmtTime, fmtDurationLong, plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||||
import MediaCard from '$lib/components/ui/media/MediaCard.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 ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||||
@@ -27,92 +18,40 @@
|
|||||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
import { createTrackMenu } from '$lib/state/menus.svelte';
|
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();
|
let { data } = $props();
|
||||||
|
|
||||||
const trackMenu = createTrackMenu();
|
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 mixes = $derived(data.mixes);
|
||||||
const playlists = $derived(data.playlists);
|
const playlists = $derived(data.playlists);
|
||||||
const spotlight = $derived(data.spotlightPlaylist);
|
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 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) {
|
function playContinue(index: number) {
|
||||||
player.playOrToggle(continueItems, index);
|
player.playOrToggle(continueItems, index);
|
||||||
}
|
}
|
||||||
function playPopular(index: number) {
|
function playSpotlightTrack(items: Track[], index: number) {
|
||||||
player.playOrToggle(popularTracks.map(toQueueItem), index);
|
player.playOrToggle(items, index);
|
||||||
}
|
}
|
||||||
function playSpotlightTrack(index: number) {
|
async function playSpotlight(previewItems: Track[]) {
|
||||||
player.playOrToggle(spotlightQueue, index);
|
if (!spotlight) return;
|
||||||
|
if (isQueueCurrent(previewItems)) {
|
||||||
|
player.toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playAllOrToggle(await fetchPlaylistTracks(spotlight.id));
|
||||||
}
|
}
|
||||||
function playSpotlight() {
|
async function shuffleSpotlight() {
|
||||||
playAllOrToggle(spotlightQueue);
|
if (!spotlight) return;
|
||||||
}
|
playShuffled(await fetchPlaylistTracks(spotlight.id));
|
||||||
function shuffleSpotlight() {
|
|
||||||
playShuffled(spotlightQueue);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
|
|
||||||
trackMenu.open(event, track);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
player.playQueue(
|
player.playQueue(mix.tracks, 0);
|
||||||
mix.items.map((item) => toQueueItem(mixItemTrack(item))),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -149,10 +88,12 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
|
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
|
||||||
{#if spotlight}
|
{#if spotlight}
|
||||||
<Button variant="primary" onclick={playSpotlight}>
|
{#await data.spotlightTracks then { items: previewItems }}
|
||||||
{heroPlaying ? 'Pausar' : 'Reanudar'}
|
<Button variant="primary" onclick={() => playSpotlight(previewItems)}>
|
||||||
{spotlight.name}
|
{isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'}
|
||||||
</Button>
|
{spotlight.name}
|
||||||
|
</Button>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +103,7 @@
|
|||||||
{@const statsList = [
|
{@const statsList = [
|
||||||
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
|
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
|
||||||
{
|
{
|
||||||
value: fmtDurationLong(Number(stats.secondsThisWeek)),
|
value: fmtDurationLong(stats.secondsThisWeek),
|
||||||
label: 'Tiempo de escucha esta semana'
|
label: 'Tiempo de escucha esta semana'
|
||||||
},
|
},
|
||||||
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
|
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
|
||||||
@@ -181,14 +122,14 @@
|
|||||||
|
|
||||||
<div class="mt-10 flex flex-col gap-section">
|
<div class="mt-10 flex flex-col gap-section">
|
||||||
<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}
|
{#if continueItems.length > 0}
|
||||||
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
{#each continueItems as item, i (item.id)}
|
{#each continueItems as item, i (item.id)}
|
||||||
{@const isCurrent = player.current.id === item.id}
|
{@const isCurrent = player.currentId === item.id}
|
||||||
<ListRow
|
<ListRow
|
||||||
onclick={() => playContinue(i)}
|
onclick={() => playContinue(i)}
|
||||||
oncontextmenu={(e) => openContextMenu(e, trackFromQueueItem(item))}
|
oncontextmenu={(e) => trackMenu.open(e, item)}
|
||||||
active={isCurrent}
|
active={isCurrent}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="card"
|
variant="card"
|
||||||
@@ -209,66 +150,75 @@
|
|||||||
|
|
||||||
{#if spotlight}
|
{#if spotlight}
|
||||||
<section>
|
<section>
|
||||||
<div
|
{#await data.spotlightTracks then { items: previewItems, totalCount }}
|
||||||
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
|
{@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'}
|
||||||
>
|
{@const meta =
|
||||||
<div class="flex flex-col justify-center p-8 sm:p-9">
|
totalCount === 0
|
||||||
<div
|
? visibilityLabel
|
||||||
role="button"
|
: `${plural(totalCount, 'canción', 'canciones')} · ${visibilityLabel}`}
|
||||||
tabindex="0"
|
<div
|
||||||
use:pressable={() => goto(`/playlists/${spotlight.id}`)}
|
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
|
||||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
>
|
||||||
>
|
<div class="flex flex-col justify-center p-8 sm:p-9">
|
||||||
<Artwork
|
<a
|
||||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
href="/playlists/{spotlight.id}"
|
||||||
spotlight.updatedAt
|
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||||
)}"
|
>
|
||||||
trackIds={spotlight.coverTrackIds}
|
<Artwork
|
||||||
size="2xl"
|
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
||||||
alt={spotlight.name}
|
spotlight.updatedAt
|
||||||
class="shrink-0"
|
)}"
|
||||||
/>
|
trackIds={spotlight.coverTrackIds}
|
||||||
<div class="flex min-w-0 flex-col gap-3">
|
size="2xl"
|
||||||
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
|
alt={spotlight.name}
|
||||||
<h3 class="truncate text-display-2 text-fg">
|
class="shrink-0"
|
||||||
{spotlight.name}
|
/>
|
||||||
</h3>
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
<p class="text-sm text-fg-2">{spotlightMeta}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
|
<div class="flex flex-col gap-2 p-5.5 sm:p-6">
|
||||||
{spotlight.description || 'Tu colección, siempre a mano.'}
|
{#each previewItems as track, i (track.id)}
|
||||||
</p>
|
<ListRow
|
||||||
<div class="mt-5 flex gap-2.5">
|
onclick={() => playSpotlightTrack(previewItems, i)}
|
||||||
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
active={player.currentId === track.id}
|
||||||
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
|
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>
|
</div>
|
||||||
<div class="flex flex-col gap-2 p-5.5 sm:p-6">
|
{/await}
|
||||||
{#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>
|
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -281,7 +231,7 @@
|
|||||||
href="/mixes/{mix.id}"
|
href="/mixes/{mix.id}"
|
||||||
title={mix.title}
|
title={mix.title}
|
||||||
subtitle={mix.subtitle}
|
subtitle={mix.subtitle}
|
||||||
trackIds={mix.items.map((item) => item.trackId)}
|
trackIds={mix.tracks.map((track) => track.id)}
|
||||||
onPlay={(event) => playMix(mix, event)}
|
onPlay={(event) => playMix(mix, event)}
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
@@ -296,45 +246,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Tus playlists"
|
title="Tus playlists"
|
||||||
@@ -384,9 +295,9 @@
|
|||||||
]}
|
]}
|
||||||
playlistAction={{
|
playlistAction={{
|
||||||
action: '?/addTrack',
|
action: '?/addTrack',
|
||||||
fields: { trackId: String(trackMenu.state.track.id) },
|
fields: { trackId: trackMenu.state.track.id },
|
||||||
label: 'Añadir a una playlist'
|
label: 'Añadir a una playlist'
|
||||||
}}
|
}}
|
||||||
{playlists}
|
playlists={data.userPlaylists}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
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';
|
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
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', {
|
const result = await api.GET('/albums/{albumId}/tracks', {
|
||||||
params: {
|
params: {
|
||||||
path: { albumId: params.id },
|
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 type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
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 }) => {
|
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
|
||||||
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
|
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
|
||||||
|
|
||||||
const body = await request.json();
|
const payload = await request.json().catch(() => null);
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
const trackId = payload?.trackId;
|
||||||
const result = await api.POST('/likes/toggle', { body });
|
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)
|
if (result.error || result.data === undefined)
|
||||||
return json({ message: 'No se pudo actualizar Me gusta.' }, { status: 502 });
|
return json({ message: 'No se pudo actualizar Me gusta.' }, { status: 502 });
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
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 }) => {
|
export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => {
|
||||||
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
|
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)
|
if (typeof playedSeconds !== 'number' || !Number.isFinite(playedSeconds) || playedSeconds < 0)
|
||||||
return json({ message: 'Tiempo inválido.' }, { status: 400 });
|
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', {
|
const result = await api.PUT('/tracks/listens/{listenId}/progress', {
|
||||||
params: { path: { listenId: params.id } },
|
params: { path: { listenId: params.id } },
|
||||||
body: { playedSeconds }
|
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 type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
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 }) => {
|
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
||||||
const name = url.searchParams.get('name')?.trim() || undefined;
|
const name = url.searchParams.get('name')?.trim() || undefined;
|
||||||
const genre = url.searchParams.get('genre')?.trim() || undefined;
|
const genre = url.searchParams.get('genre')?.trim() || undefined;
|
||||||
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
|
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', {
|
const result = await api.GET('/tracks', {
|
||||||
params: { query: { name, genre, pageNumber, pageSize } }
|
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 type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
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 }) => {
|
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', {
|
const result = await api.GET('/tracks/{id}/stream', {
|
||||||
params: { path: { id: params.id } }
|
params: { path: { id: params.id } }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
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 { fetchFollowList } from '$lib/server/follows';
|
||||||
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
|
export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
|
||||||
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
|
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(
|
const result = await fetchFollowList(
|
||||||
api,
|
api,
|
||||||
params.list,
|
params.list,
|
||||||
@@ -15,5 +16,10 @@ export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
|
|||||||
FOLLOW_LIST_PAGE_SIZE
|
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