10 Commits
Author SHA1 Message Date
Admin b9a40186b1 Lighten active surface gradients in light mode
CI/CD / BackEnd (push) Successful in 1m15s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 22:44:42 +02:00
Admin fd0a39b879 IsExplicit 2026-09-23 22:42:28 +02:00
Admin cc7afe4326 Add explicit flag to tracks and mark it on upload 2026-09-23 22:41:57 +02:00
Admin 72cbc18a48 Add follow button next to each user in search results
Extract FollowButton and shared follow/unfollow actions, reused by the user profile page.
2026-09-23 22:34:56 +02:00
Admin 84408b842b Semibold 2026-09-23 22:25:04 +02:00
Admin 339fcb56ad Semibold 2026-09-23 22:24:57 +02:00
Admin 74cf534be5 Animate queue drag and drop reordering 2026-09-23 22:18:21 +02:00
Admin 89820c1377 Fix gradient background syntax in Artwork component 2026-09-23 22:10:46 +02:00
Admin 2fe26b15dd Allow reordering upcoming tracks in the queue by drag and drop 2026-09-23 22:10:21 +02:00
Admin 2cb560fda4 Keep light mode gradients visible while drifting 2026-09-23 22:07:50 +02:00
27 changed files with 1810 additions and 395 deletions
@@ -18,7 +18,8 @@ namespace Musify.Application.Tracks
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags) IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
: ICommand<ErrorOr<TrackApplicationResponse>>; : ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler( public class CreateTrackCommandHandler(
@@ -83,6 +84,7 @@ namespace Musify.Application.Tracks
NormalizedTitle = request.Title.ToUpperInvariant(), NormalizedTitle = request.Title.ToUpperInvariant(),
OwnerUserId = user.Id, OwnerUserId = user.Id,
Owner = user, Owner = user,
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures Pictures = new TrackPictures
{ {
OriginalName = pictureIntent.ObjectName, OriginalName = pictureIntent.ObjectName,
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
IReadOnlyCollection<Genre> Tags) IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
{ {
public static TrackApplicationResponse FromEntity(Track track, int listensCount) => public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new( new(
@@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses
track.CreatedAt, track.CreatedAt,
track.UpdatedAt, track.UpdatedAt,
track.OwnerUserId, track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList()); track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit);
} }
} }
@@ -31,6 +31,9 @@ namespace Musify.Domain.Entities
[Required] [Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public bool IsExplicit { get; set; }
[Required] [Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackIsExplicit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsExplicit",
table: "Tracks",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsExplicit",
table: "Tracks");
}
}
}
@@ -427,6 +427,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<double>("DurationSeconds") b.Property<double>("DurationSeconds")
.HasColumnType("double precision"); .HasColumnType("double precision");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus") b.Property<int>("LifeCycleStatus")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -6,4 +6,5 @@ public record CreateTrackRequest(
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags); IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false);
@@ -147,7 +147,7 @@ public static class TrackEndpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags), new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit),
cancellationToken); cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}"); return result.ToCreatedResult(track => $"/tracks/{track.Id}");
@@ -17,6 +17,17 @@ namespace Musify.Application.Tests.Shared
Assert.Equal("The Owner", response.Artist); Assert.Equal("The Owner", response.Artist);
Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount); Assert.Equal(3, response.ListensCount);
Assert.False(response.IsExplicit);
}
[Fact]
public void FromEntity_CopiesExplicitFlag()
{
var track = TestEntities.Track(TestEntities.User(), isExplicit: true);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
Assert.True(response.IsExplicit);
} }
} }
} }
@@ -56,7 +56,8 @@ namespace Musify.Application.Tests.TestSupport
TrackPictures? pictures = null, TrackPictures? pictures = null,
TrackAudio? audio = null, TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null) IEnumerable<Genre>? tags = null,
bool isExplicit = false)
{ {
var track = new Track var track = new Track
{ {
@@ -64,6 +65,7 @@ namespace Musify.Application.Tests.TestSupport
NormalizedTitle = title.ToUpperInvariant(), NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus, LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
OwnerUserId = owner.Id, OwnerUserId = owner.Id,
Owner = owner, Owner = owner,
Pictures = pictures ?? ProcessedPictures(), Pictures = pictures ?? ProcessedPictures(),
@@ -58,6 +58,26 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
} }
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Handle_StoresExplicitFlag(bool isExplicit)
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(isExplicit, result.Value.IsExplicit);
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(isExplicit, stored.IsExplicit);
}
[Fact] [Fact]
public async Task Handle_NoTags_ReturnsValidationError() public async Task Handle_NoTags_ReturnsValidationError()
{ {
+267 -259
View File
File diff suppressed because it is too large Load Diff
@@ -5,12 +5,36 @@
import ListRow from '$lib/components/ui/media/ListRow.svelte'; import ListRow from '$lib/components/ui/media/ListRow.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import { fmtTime } from '$lib/utils/format'; import { fmtTime } from '$lib/utils/format';
import { flip } from 'svelte/animate';
import { expoOut } from 'svelte/easing';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
const upcoming = $derived.by(() => { const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId); const idx = player.tracks.findIndex((t) => t.id === player.currentId);
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1); return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
}); });
let dragFrom = $state<number | null>(null);
let dropAt = $state<number | null>(null);
function onDragOver(event: DragEvent, index: number) {
if (dragFrom === null) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
dropAt = event.clientY < rect.top + rect.height / 2 ? index : index + 1;
}
function onDrop(event: DragEvent) {
event.preventDefault();
if (dragFrom !== null && dropAt !== null) player.moveQueueItem(dragFrom, dropAt);
endDrag();
}
function endDrag() {
dragFrom = null;
dropAt = null;
}
</script> </script>
<div <div
@@ -37,6 +61,7 @@
title={player.current.title} title={player.current.title}
artist={player.current.artist || '—'} artist={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId} ownerUserId={player.current.ownerUserId}
explicit={player.current.explicit}
active active
/> />
</div> </div>
@@ -55,24 +80,63 @@
{/if} {/if}
</div> </div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-1 flex-col gap-px overflow-y-auto"
ondragover={(event) => dragFrom !== null && event.preventDefault()}
ondrop={onDrop}
>
{#each upcoming as track, i (track.id)} {#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i} {@const index = player.tracks.length - upcoming.length + i}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
draggable="true"
ondragstart={(event) => {
dragFrom = index;
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}}
ondragover={(event) => onDragOver(event, index)}
ondrop={onDrop}
ondragend={endDrag}
class="queue-row"
data-dragging={dragFrom === index ? '' : undefined}
data-drop={dragFrom === null
? undefined
: dropAt === index
? 'before'
: dropAt === index + 1 && index === player.tracks.length - 1
? 'after'
: undefined}
animate:flip={{ duration: 300, easing: expoOut }}
>
{#if dropAt === index && dragFrom !== null}
<div
class="pointer-events-none absolute inset-x-2 -top-7 h-0.5 rounded-full bg-accent"
></div>
{:else if dropAt === index + 1 && dropAt === player.tracks.length && dragFrom !== null}
<div
class="pointer-events-none absolute inset-x-2 -bottom-7 h-0.5 rounded-full bg-accent"
></div>
{/if}
<ListRow <ListRow
onclick={() => player.playQueueIndex(index)} onclick={() => player.playQueueIndex(index)}
size="sm" size="sm"
title={track.title} title={track.title}
subtitle={track.artist || '—'} subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
explicit={track.explicit}
trackId={track.id} trackId={track.id}
class="p-2" class="p-2"
> >
{#snippet trailing()} {#snippet trailing()}
{#if track.duration} {#if track.duration}
<span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(track.duration)}</span> <span class="shrink-0 text-xs text-fg-2 tabular-nums"
>{fmtTime(track.duration)}</span
>
{/if} {/if}
{/snippet} {/snippet}
</ListRow> </ListRow>
</div>
{:else} {:else}
<p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p> <p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p>
{/each} {/each}
@@ -125,7 +125,7 @@
</svg> </svg>
</div> </div>
{:else if fallback === 'gradient'} {:else if fallback === 'gradient'}
<div class="h-full w-full bg-[image:var(--mf-cover-grad)]"></div> <div class="h-full w-full bg-(image:--mf-cover-grad)"></div>
{:else} {:else}
<div class="h-full w-full bg-surface"></div> <div class="h-full w-full bg-surface"></div>
{/if} {/if}
@@ -7,7 +7,7 @@
</script> </script>
<span <span
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-2 text-xs leading-4 font-bold text-bg {klass}" class="grid size-4 shrink-0 place-items-center rounded-tag border border-accent/30 bg-accent-tint pt-px text-xs leading-none font-normal text-accent-soft {klass}"
title="Contenido explícito" title="Contenido explícito"
aria-label="Contenido explícito">E</span aria-label="Contenido explícito">E</span
> >
@@ -0,0 +1,44 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Button from '../primitives/Button.svelte';
interface Props {
following: boolean;
userId?: string;
invalidateAll?: boolean;
class?: string;
}
let { following: initial, userId, invalidateAll = false, class: klass = '' }: Props = $props();
let following = $state(untrack(() => initial));
let submitting = $state(false);
</script>
<form
method="POST"
action={following ? '?/unfollow' : '?/follow'}
class={klass}
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
if (result.type === 'success' && typeof result.data?.following === 'boolean')
following = result.data.following;
await update({ reset: false, invalidateAll });
submitting = false;
};
}}
>
{#if userId}
<input type="hidden" name="userId" value={userId} />
{/if}
<Button
type="submit"
size="sm"
variant={following ? 'secondary' : 'primary'}
disabled={submitting}
>
{following ? 'Dejar de seguir' : 'Seguir'}
</Button>
</form>
@@ -456,6 +456,16 @@ class PlayerState {
this.#loadCurrent(); this.#loadCurrent();
} }
moveQueueItem(from: number, insertAt: number) {
if (from < 0 || from >= this.tracks.length) return;
const target = insertAt > from ? insertAt - 1 : insertAt;
if (target === from) return;
const tracks = [...this.tracks];
const [item] = tracks.splice(from, 1);
tracks.splice(target, 0, item);
this.tracks = tracks;
}
clearUpcoming() { clearUpcoming() {
this.tracks = this.currentId === null ? [] : [this.current]; this.tracks = this.currentId === null ? [] : [this.current];
} }
@@ -0,0 +1,28 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const id = String(form.get('userId') ?? '') || String(params.id ?? '');
if (!id) return fail(400, { message: 'Falta el usuario.' });
const api = createApiClient({ fetch, accessToken });
const options = { params: { path: { id } } };
const { error } = follow
? await api.POST('/users/{id}/follow', options)
: await api.DELETE('/users/{id}/follow', options);
if (error) {
return fail(502, {
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
});
}
return { following: follow, userId: id };
}
export const followUserAction = (event: RequestEvent) => setFollowing(event, true);
export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false);
+4 -4
View File
@@ -22,10 +22,10 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
const g = (l1: number, c1: number, a1: number, l2: number, c2: number, a2: number) => const g = (l1: number, c1: number, a1: number, l2: number, c2: number, a2: number) =>
`linear-gradient(90deg, oklch(${l1} ${c1} ${H} / ${a1}), oklch(${l2} ${c2} ${H} / ${a2}))`; `linear-gradient(90deg, oklch(${l1} ${c1} ${H} / ${a1}), oklch(${l2} ${c2} ${H} / ${a2}))`;
const gActive = light const gActive = light
? g(0.82, 0.05, 0.55, 0.87, 0.035, 0.36) ? g(0.86, 0.04, 0.45, 0.9, 0.03, 0.28)
: g(0.46, 0.06, 0.34, 0.4, 0.045, 0.22); : g(0.46, 0.06, 0.34, 0.4, 0.045, 0.22);
const gHover = light const gHover = light
? g(0.86, 0.035, 0.38, 0.9, 0.025, 0.24) ? g(0.9, 0.03, 0.34, 0.93, 0.02, 0.2)
: g(0.42, 0.045, 0.26, 0.38, 0.035, 0.17); : g(0.42, 0.045, 0.26, 0.38, 0.035, 0.17);
const glow = (l: number, c: number, a: number) => `oklch(${l} ${c} ${H} / ${a})`; const glow = (l: number, c: number, a: number) => `oklch(${l} ${c} ${H} / ${a})`;
// Fixed danger hue so error dialogs read red whatever the theme hue is. // Fixed danger hue so error dialogs read red whatever the theme hue is.
@@ -54,7 +54,7 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
: `linear-gradient(90deg, oklch(0.66 0.11 ${H}), oklch(0.84 0.09 ${r(h + 18, 1)}), oklch(0.66 0.11 ${H}))`, : `linear-gradient(90deg, oklch(0.66 0.11 ${H}), oklch(0.84 0.09 ${r(h + 18, 1)}), oklch(0.66 0.11 ${H}))`,
'--mf-hero-tint': light ? `oklch(0.8 0.05 ${H})` : `oklch(0.3 0.04 ${H})`, '--mf-hero-tint': light ? `oklch(0.8 0.05 ${H})` : `oklch(0.3 0.04 ${H})`,
'--mf-hero-bg': light '--mf-hero-bg': light
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.6)}, ${glow(0.96, 0.02, 0.4)} 60%)` ? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.6)}, ${glow(0.93, 0.03, 0.52)} 60%)`
: `linear-gradient(120deg, ${glow(0.24, 0.04, 0.55)}, ${glow(0.14, 0.02, 0.35)} 60%, ${glow(0.12, 0.015, 0.25)})`, : `linear-gradient(120deg, ${glow(0.24, 0.04, 0.55)}, ${glow(0.14, 0.02, 0.35)} 60%, ${glow(0.12, 0.015, 0.25)})`,
'--mf-modal-glow': light '--mf-modal-glow': light
? `linear-gradient(135deg, ${glow(0.88, 0.05, 0.5)} 0%, ${glow(0.93, 0.03, 0.2)} 40%, transparent 75%)` ? `linear-gradient(135deg, ${glow(0.88, 0.05, 0.5)} 0%, ${glow(0.93, 0.03, 0.2)} 40%, transparent 75%)`
@@ -63,7 +63,7 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
? `linear-gradient(135deg, ${dangerGlow(0.86, 0.07, 0.55)} 0%, ${dangerGlow(0.92, 0.04, 0.22)} 40%, transparent 75%)` ? `linear-gradient(135deg, ${dangerGlow(0.86, 0.07, 0.55)} 0%, ${dangerGlow(0.92, 0.04, 0.22)} 40%, transparent 75%)`
: `linear-gradient(135deg, ${dangerGlow(0.34, 0.1, 0.45)} 0%, ${dangerGlow(0.22, 0.06, 0.18)} 35%, transparent 70%)`, : `linear-gradient(135deg, ${dangerGlow(0.34, 0.1, 0.45)} 0%, ${dangerGlow(0.22, 0.06, 0.18)} 35%, transparent 70%)`,
'--mf-spotlight-bg': light '--mf-spotlight-bg': light
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.55)}, ${glow(0.96, 0.02, 0.35)} 55%)` ? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.55)}, ${glow(0.93, 0.03, 0.47)} 55%)`
: `linear-gradient(120deg, ${glow(0.22, 0.04, 0.5)}, ${glow(0.13, 0.02, 0.3)} 55%, ${glow(0.11, 0.015, 0.2)})`, : `linear-gradient(120deg, ${glow(0.22, 0.04, 0.5)}, ${glow(0.13, 0.02, 0.3)} 55%, ${glow(0.11, 0.015, 0.2)})`,
'--mf-cover-grad': light '--mf-cover-grad': light
? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))` ? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))`
@@ -1,6 +1,7 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { createApiClient, unwrapOrError } from '$lib/server/api';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions'; import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
import { import {
EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE, EXPLORE_PAGE_SIZE as PAGE_SIZE,
@@ -45,16 +46,38 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'); const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
const userItems = usersRes?.data?.items ?? [];
const followedIds = user
? await Promise.all(
userItems
.filter((u) => u.id !== user.sub)
.map((u) =>
api
.GET('/users/{id}/is-following', { params: { path: { id: u.id } } })
.then((res) => (res.data ? u.id : null))
.catch(() => null)
)
)
: [];
const followed = new Set(followedIds);
return { return {
query, query,
tracks, tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album), albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
users: usersRes?.data?.items ?? [], users: userItems.map((u) => ({
...u,
isSelf: user?.sub === u.id,
isFollowing: followed.has(u.id)
})),
canFollow: !!user,
playlists: playlistsRes?.data?.items ?? [] playlists: playlistsRes?.data?.items ?? []
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
addTrack: addTrackAction, addTrack: addTrackAction,
addAlbumToPlaylist: addAlbumToPlaylistAction addAlbumToPlaylist: addAlbumToPlaylistAction,
follow: followUserAction,
unfollow: unfollowUserAction
}; };
@@ -10,6 +10,7 @@
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte';
import Avatar from '$lib/components/ui/media/Avatar.svelte'; import Avatar from '$lib/components/ui/media/Avatar.svelte';
import FollowButton from '$lib/components/ui/media/FollowButton.svelte';
import PlayButton from '$lib/components/ui/media/PlayButton.svelte'; import PlayButton from '$lib/components/ui/media/PlayButton.svelte';
import EqBars from '$lib/components/ui/media/EqBars.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';
@@ -337,6 +338,7 @@
title={track.title} title={track.title}
subtitle={track.artist} subtitle={track.artist}
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
active={player.current.id === track.id} active={player.current.id === track.id}
size="lg" size="lg"
trackId={track.id} trackId={track.id}
@@ -400,12 +402,15 @@
<SectionHeading title="Usuarios" count={counts.Usuarios} /> <SectionHeading title="Usuarios" count={counts.Usuarios} />
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
{#each capped(sfilter, users) as u (u.id)} {#each capped(sfilter, users) as u (u.id)}
<li> <li class="flex items-center gap-3">
<ListRow title={u.name} href="/user/{u.id}" size="lg"> <ListRow title={u.name} href="/user/{u.id}" size="lg" class="min-w-0 flex-1">
{#snippet art()} {#snippet art()}
<Avatar name={u.name} src={u.profilePictureUrl} size="md" /> <Avatar name={u.name} src={u.profilePictureUrl} size="md" />
{/snippet} {/snippet}
</ListRow> </ListRow>
{#if data.canFollow && !u.isSelf}
<FollowButton following={u.isFollowing} userId={u.id} class="shrink-0" />
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -25,6 +25,7 @@ export const actions: Actions = {
const audio = form.get('audio'); const audio = form.get('audio');
const cover = form.get('cover'); const cover = form.get('cover');
const rawTags = form.getAll('tags').map(String); const rawTags = form.getAll('tags').map(String);
const isExplicit = form.get('isExplicit') === 'on';
if (title === '' || title.length > MAX_TITLE) { if (title === '' || title.length > MAX_TITLE) {
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' }); return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
@@ -90,7 +91,8 @@ export const actions: Actions = {
title, title,
pictureIntentId: urls.pictureIntentId, pictureIntentId: urls.pictureIntentId,
audioIntentId: urls.audioIntentId, audioIntentId: urls.audioIntentId,
tags tags,
isExplicit
} }
}); });
@@ -13,6 +13,7 @@
import Input from '$lib/components/ui/primitives/Input.svelte'; import Input from '$lib/components/ui/primitives/Input.svelte';
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte'; import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
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 Chip from '$lib/components/ui/primitives/Chip.svelte'; import Chip from '$lib/components/ui/primitives/Chip.svelte';
import { TRACK_GENRES, areCompatible, type TrackGenre } from '$lib/data/trackGenres'; import { TRACK_GENRES, areCompatible, type TrackGenre } from '$lib/data/trackGenres';
@@ -29,6 +30,7 @@
let dragging = $state(false); let dragging = $state(false);
let status = $state<Status>('idle'); let status = $state<Status>('idle');
let acceptedTerms = $state(false); let acceptedTerms = $state(false);
let isExplicit = $state(false);
let errorMsg = $state(''); let errorMsg = $state('');
let errorDetail = $state(''); let errorDetail = $state('');
let tags = $state<TrackGenre[]>([]); let tags = $state<TrackGenre[]>([]);
@@ -101,6 +103,7 @@
coverResetToken += 1; coverResetToken += 1;
status = 'idle'; status = 'idle';
acceptedTerms = false; acceptedTerms = false;
isExplicit = false;
errorMsg = ''; errorMsg = '';
errorDetail = ''; errorDetail = '';
tags = []; tags = [];
@@ -224,19 +227,23 @@
{/if} {/if}
</div> </div>
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8"> <Surface padding="md">
<Field label="Portada"> <div class="grid items-start gap-6 md:grid-cols-[auto_1fr] md:gap-10">
<Field label="Portada" class="items-center md:items-start">
{#key coverResetToken} {#key coverResetToken}
<ImageDropzone <ImageDropzone
name="cover" name="cover"
icon={ImageIcon} icon={ImageIcon}
gradient gradient
size="hero"
onselect={() => (hasCover = true)} onselect={() => (hasCover = true)}
/> />
{/key} {/key}
{#snippet hint()}
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir'}</p>
{/snippet}
</Field> </Field>
<div class="flex min-w-0 flex-col gap-7">
<Field label="Título" for="title"> <Field label="Título" for="title">
<Input <Input
id="title" id="title"
@@ -250,10 +257,9 @@
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span> <span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
{/snippet} {/snippet}
</Field> </Field>
</div>
<Field label="Géneros"> <Field label="Géneros">
<div class="flex flex-wrap gap-2"> <div class="mb-2 flex flex-wrap gap-2">
{#each TRACK_GENRES as genre (genre.value)} {#each TRACK_GENRES as genre (genre.value)}
<Chip <Chip
selected={tags.includes(genre.value)} selected={tags.includes(genre.value)}
@@ -268,10 +274,23 @@
<input type="hidden" name="tags" value={tag} /> <input type="hidden" name="tags" value={tag} />
{/each} {/each}
{#snippet hint()} {#snippet hint()}
<p>Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient.</p> <p>
Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient.
</p>
<span class="shrink-0 tabular-nums">{tags.length} elegidos</span>
{/snippet} {/snippet}
</Field> </Field>
<Checkbox bind:checked={isExplicit} name="isExplicit">
<span class="inline-flex items-center gap-2">
Contenido explícito
<ExplicitBadge />
</span>
</Checkbox>
</div>
</div>
</Surface>
<Checkbox bind:checked={acceptedTerms}> <Checkbox bind:checked={acceptedTerms}>
Acepto los Acepto los
<a <a
@@ -1,10 +1,6 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
createApiClient, import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
optionalUser,
requireAccessTokenAction,
unwrapOrError
} from '$lib/server/api';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50; const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
@@ -55,21 +51,6 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
}; };
export const actions: Actions = { export const actions: Actions = {
follow: async ({ params, locals, fetch }) => { follow: followUserAction,
const accessToken = requireAccessTokenAction(locals); unfollow: unfollowUserAction
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.POST('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: true };
},
unfollow: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.DELETE('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: false };
}
}; };
@@ -1,29 +1,19 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
import MediaCard from '$lib/components/ui/media/MediaCard.svelte'; import MediaCard from '$lib/components/ui/media/MediaCard.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 FollowButton from '$lib/components/ui/media/FollowButton.svelte';
import Avatar from '$lib/components/ui/media/Avatar.svelte'; import Avatar from '$lib/components/ui/media/Avatar.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import { plural, playlistMeta } from '$lib/utils/format'; import { plural, playlistMeta } from '$lib/utils/format';
let { data, form } = $props(); let { data } = $props();
const profile = $derived(data.profile); const profile = $derived(data.profile);
const playlists = $derived(data.playlists); const playlists = $derived(data.playlists);
const canFollow = $derived(!data.isOwnProfile && !data.isAnonymous); const canFollow = $derived(!data.isOwnProfile && !data.isAnonymous);
let following = $state(untrack(() => data.isFollowing));
let submitting = $state(false);
$effect(() => {
if (form && 'following' in form && typeof form.following === 'boolean')
following = form.following;
});
</script> </script>
<svelte:head> <svelte:head>
@@ -32,26 +22,7 @@
</svelte:head> </svelte:head>
{#snippet followAction()} {#snippet followAction()}
<form <FollowButton following={data.isFollowing} invalidateAll />
method="POST"
action={following ? '?/unfollow' : '?/follow'}
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
>
<Button
type="submit"
size="sm"
variant={following ? 'secondary' : 'primary'}
disabled={submitting}
>
{following ? 'Dejar de seguir' : 'Seguir'}
</Button>
</form>
{/snippet} {/snippet}
<Page> <Page>
+2
View File
@@ -195,6 +195,7 @@
title={item.title} title={item.title}
subtitle={item.artist} subtitle={item.artist}
subtitleHref={item.ownerUserId} subtitleHref={item.ownerUserId}
explicit={item.explicit}
trackId={item.id} trackId={item.id}
class="animate-enter" class="animate-enter"
style="--i:{i}" style="--i:{i}"
@@ -252,6 +253,7 @@
title={track.title} title={track.title}
subtitle={track.artist} subtitle={track.artist}
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
> >
{#snippet leading()} {#snippet leading()}
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums" <span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
+23 -5
View File
@@ -206,6 +206,24 @@
visibility: visible; visibility: visible;
} }
} }
@utility queue-row {
position: relative;
transition:
margin var(--mf-motion-base) var(--mf-ease),
opacity var(--mf-motion-base) var(--mf-ease),
scale var(--mf-motion-base) var(--mf-ease);
&[data-dragging] {
opacity: 0.4;
scale: 0.97;
}
&[data-drop='before'] {
margin-top: --spacing(14);
}
&[data-drop='after'] {
margin-bottom: --spacing(14);
}
}
@utility app-main { @utility app-main {
--mf-dock-h: 0px; --mf-dock-h: 0px;
padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h)); padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h));
@@ -224,22 +242,22 @@
} }
@utility surface-active { @utility surface-active {
background-image: var(--mf-g-active); background-image: var(--mf-g-active);
background-size: 180% 100%; background-size: 125% 100%;
animation: gradient-drift 14s ease-in-out infinite alternate; animation: gradient-drift 14s ease-in-out infinite alternate;
} }
@utility surface-active-hover { @utility surface-active-hover {
background-image: var(--mf-g-hover); background-image: var(--mf-g-hover);
background-size: 180% 100%; background-size: 125% 100%;
animation: gradient-drift 14s ease-in-out infinite alternate; animation: gradient-drift 14s ease-in-out infinite alternate;
} }
@utility hero-surface { @utility hero-surface {
background-image: var(--mf-hero-bg); background-image: var(--mf-hero-bg);
background-size: 180% 100%; background-size: 125% 100%;
animation: gradient-drift 18s ease-in-out infinite alternate; animation: gradient-drift 18s ease-in-out infinite alternate;
} }
@utility spotlight-surface { @utility spotlight-surface {
background-size: background-size:
180% 100%, 125% 100%,
100% 100%; 100% 100%;
animation: gradient-drift 18s ease-in-out infinite alternate; animation: gradient-drift 18s ease-in-out infinite alternate;
background-image: background-image:
@@ -252,7 +270,7 @@
); );
} }
@utility glow-drift { @utility glow-drift {
background-size: 180% 100%; background-size: 125% 100%;
animation: gradient-drift 18s ease-in-out infinite alternate; animation: gradient-drift 18s ease-in-out infinite alternate;
} }
@utility field-control { @utility field-control {