refactor: tighten the home typography and card craft

The playlist cards were showing "No description was pr…" because the API
stored that English sentence whenever a playlist was created without a
description. Descriptions are optional now and the old rows are cleared.

Section headings drop to 18/20px so they stop competing with the content,
and the space moved from inside the sections to between them.

The three media cards repeated the same anchor, art class and label
block, so they share a MediaCard that also carries the hover lift the
track tiles already had.
This commit is contained in:
2026-07-21 23:14:36 +02:00
parent 0b149f052e
commit 611b2a72cd
26 changed files with 1317 additions and 88 deletions
@@ -2,5 +2,5 @@ namespace Musify.Api.DataTransferObjects.PlayLists;
public record CreatePlayListRequest( public record CreatePlayListRequest(
string Name, string Name,
string Description, string? Description,
Guid? PictureIntentId); Guid? PictureIntentId);
@@ -181,12 +181,8 @@ public static class PlayListEndpoints
CreatePlayListRequest request, CreatePlayListRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var description = string.IsNullOrWhiteSpace(request.Description)
? "No description was provided."
: request.Description;
var result = await mediator.Send( var result = await mediator.Send(
new CreatePlayListCommand(currentUser.RequiredId, request.Name, description, request.PictureIntentId), new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId),
cancellationToken); cancellationToken);
return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); return result.ToCreatedResult(playList => $"/playlists/{playList.Id}");
@@ -16,7 +16,7 @@ namespace Musify.Application.PlayLists
public record CreatePlayListCommand( public record CreatePlayListCommand(
long UserId, long UserId,
string Name, string Name,
string Description, string? Description,
Guid? PictureIntentId) Guid? PictureIntentId)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
@@ -58,7 +58,7 @@ namespace Musify.Application.PlayLists
UserId = request.UserId, UserId = request.UserId,
Name = request.Name, Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(), NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = request.Description, Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = pictureIntent == null ? null Pictures = pictureIntent == null ? null
: new PlayListPictures { OriginalName = pictureIntent.ObjectName } : new PlayListPictures { OriginalName = pictureIntent.ObjectName }
}; };
+1 -2
View File
@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.Abstractions; using Musify.Domain.Abstractions;
@@ -24,7 +24,6 @@ namespace Musify.Domain.Entities
[MaxLength(50)] [MaxLength(50)]
public required string NormalizedName { get; set; } public required string NormalizedName { get; set; }
[Required]
[MaxLength(256)] [MaxLength(256)]
public string? Description { get; set; } public string? Description { get; set; }
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MakePlayListDescriptionOptional : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Description",
table: "PlayLists",
type: "character varying(256)",
maxLength: 256,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256);
migrationBuilder.Sql(
"""
UPDATE "PlayLists" SET "Description" = NULL
WHERE "Description" = 'No description was provided.';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Description",
table: "PlayLists",
type: "character varying(256)",
maxLength: 256,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256,
oldNullable: true);
}
}
}
@@ -409,7 +409,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("Description") b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
@@ -22,7 +22,7 @@
<header <header
class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-line bg-bg/85 page-x backdrop-blur-md md:hidden" class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-line bg-bg/85 page-x backdrop-blur-md md:hidden"
> >
<a href="/" class="text-lg font-extrabold tracking-wide">Musify</a> <a href="/" class="text-lg font-semibold tracking-wide">Musify</a>
<div class="relative" bind:this={menuRef}> <div class="relative" bind:this={menuRef}>
<button <button
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.svelte'; import PlaylistArt from './PlaylistArt.svelte';
import Cover from './Cover.svelte'; import Cover from './Cover.svelte';
import { hueFor } from '$lib/theme/color'; import { hueFor } from '$lib/theme/color';
@@ -45,30 +46,25 @@
); );
</script> </script>
<a <MediaCard
href={href ?? `/albums/${id}`} href={href ?? `/albums/${id}`}
class="group animate-enter min-w-0 text-left" {title}
style="animation-delay:{index * 45}ms" subtitle={meta}
{index}
oncontextmenu={onContextMenu} oncontextmenu={onContextMenu}
> >
{#if coverSrc} {#snippet art(artClass)}
<Cover {#if coverSrc}
trackId={id} <Cover
src={coverSrc} trackId={id}
hue={hueFor(id)} src={coverSrc}
size="large" hue={hueFor(id)}
alt={title} size="large"
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg" alt={title}
/> class={artClass}
{:else} />
<PlaylistArt {:else}
{trackIds} <PlaylistArt {trackIds} hue={hueFor(id)} class={artClass} />
hue={hueFor(id)} {/if}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg" {/snippet}
/> </MediaCard>
{/if}
<div class="mt-2.5 truncate text-fg">{title}</div>
{#if meta}
<div class="mt-0.5 truncate text-sm text-fg-3">{meta}</div>
{/if}
</a>
@@ -20,7 +20,7 @@
</span> </span>
{/if} {/if}
{#if title} {#if title}
<h3 class="mt-5 text-2xl font-bold tracking-tight text-fg">{title}</h3> <h3 class="mt-5 text-2xl font-semibold tracking-tight text-fg">{title}</h3>
{/if} {/if}
{#if description} {#if description}
<p class="mt-2 text-fg-2">{description}</p> <p class="mt-2 text-fg-2">{description}</p>
@@ -0,0 +1,32 @@
<script lang="ts" module>
export const mediaCardArt =
'aspect-square w-full rounded-art shadow-art transition duration-300 ease-out group-hover:-translate-y-1 group-hover:shadow-art-lg';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
href: string;
title: string;
subtitle?: string | null;
index?: number;
oncontextmenu?: (event: MouseEvent) => void;
art: Snippet<[string]>;
}
let { href, title, subtitle, index = 0, oncontextmenu, art }: Props = $props();
</script>
<a
{href}
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
{oncontextmenu}
>
{@render art(mediaCardArt)}
<div class="mt-2.5 truncate text-fg transition-colors group-hover:text-accent-soft">{title}</div>
{#if subtitle}
<div class="mt-0.5 truncate text-sm text-fg-3">{subtitle}</div>
{/if}
</a>
@@ -11,7 +11,7 @@
let { let {
as = 'div', as = 'div',
min = '200px', min = '165px',
minMobile = '150px', minMobile = '150px',
class: klass = '', class: klass = '',
children children
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import MediaCard from './MediaCard.svelte';
import MixArt from './MixArt.svelte'; import MixArt from './MixArt.svelte';
import { hueFor } from '$lib/theme/color'; import { hueFor } from '$lib/theme/color';
import type { Mix } from '$lib/mixes'; import type { Mix } from '$lib/mixes';
@@ -13,19 +14,13 @@
const count = $derived(Number(mix.itemCount)); const count = $derived(Number(mix.itemCount));
</script> </script>
<a <MediaCard
href="/mixes/{mix.id}" href="/mixes/{mix.id}"
class="group animate-enter min-w-0 text-left" title={mix.title}
style="animation-delay:{index * 45}ms" subtitle="{count} {count === 1 ? 'canción' : 'canciones'}"
{index}
> >
<MixArt {#snippet art(artClass)}
items={mix.items} <MixArt items={mix.items} hue={hueFor(mix.id)} class={artClass} />
hue={hueFor(mix.id)} {/snippet}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg" </MediaCard>
/>
<div class="mt-2.5 truncate text-fg">{mix.title}</div>
<div class="mt-0.5 truncate text-sm text-fg-3">
{count}
{count === 1 ? 'canción' : 'canciones'}
</div>
</a>
@@ -82,7 +82,7 @@
> >
{#if title} {#if title}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight text-fg">{title}</h2> <h2 class="text-lg font-semibold tracking-tight text-fg">{title}</h2>
<IconButton label="Cerrar" onclick={onClose}> <IconButton label="Cerrar" onclick={onClose}>
<X class="h-4 w-4" /> <X class="h-4 w-4" />
</IconButton> </IconButton>
@@ -13,7 +13,7 @@
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}"> <div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
<div class="min-w-0"> <div class="min-w-0">
<h1 class="text-2xl font-bold tracking-tight text-fg sm:text-4xl">{title}</h1> <h1 class="text-2xl font-semibold tracking-tight text-fg sm:text-4xl">{title}</h1>
{#if subtitle} {#if subtitle}
<p class="mt-2 text-sm text-fg-2 sm:text-base">{subtitle}</p> <p class="mt-2 text-sm text-fg-2 sm:text-base">{subtitle}</p>
{/if} {/if}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.svelte'; import PlaylistArt from './PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color'; import { hueFor } from '$lib/theme/color';
@@ -14,20 +15,8 @@
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props(); let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
</script> </script>
<a <MediaCard href="/playlists/{id}" title={name} subtitle={description} {index}>
href="/playlists/{id}" {#snippet art(artClass)}
class="group animate-enter min-w-0 text-left" <PlaylistArt playlistId={id} {trackIds} hue={hueFor(id)} version={updatedAt} class={artClass} />
style="animation-delay:{index * 45}ms" {/snippet}
> </MediaCard>
<PlaylistArt
playlistId={id}
{trackIds}
hue={hueFor(id)}
version={updatedAt}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
<div class="mt-2.5 truncate text-fg">{name}</div>
{#if description}
<div class="mt-0.5 truncate text-sm text-fg-3">{description}</div>
{/if}
</a>
@@ -11,10 +11,12 @@
</script> </script>
{#if actions} {#if actions}
<div class="mb-4 flex items-center justify-between gap-4 sm:mb-6 {klass}"> <div class="mb-3 flex items-baseline justify-between gap-4 sm:mb-4 {klass}">
<h2 class="text-xl font-bold text-fg sm:text-2xl">{title}</h2> <h2 class="text-lg font-semibold tracking-tight text-fg sm:text-xl">{title}</h2>
{@render actions()} {@render actions()}
</div> </div>
{:else} {:else}
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2> <h2 class="mb-3 text-lg font-semibold tracking-tight text-fg sm:mb-4 sm:text-xl {klass}">
{title}
</h2>
{/if} {/if}
@@ -77,7 +77,7 @@
</Modal> </Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm"> <Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-bold tracking-tight text-fg"> <h2 class="text-lg font-semibold tracking-tight text-fg">
¿Eliminar «{album.title}»? ¿Eliminar «{album.title}»?
</h2> </h2>
<p class="mt-2 text-base text-fg-3"> <p class="mt-2 text-base text-fg-3">
@@ -64,7 +64,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Álbum</p> <p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Álbum</p>
<h1 <h1
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl" class="mt-1.5 text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
> >
{title} {title}
</h1> </h1>
@@ -103,7 +103,7 @@
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'} · YouTube Music {album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'} · YouTube Music
</p> </p>
<h1 <h1
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl" class="mt-1.5 text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
> >
{album.title} {album.title}
</h1> </h1>
@@ -85,7 +85,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Mezcla</p> <p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Mezcla</p>
<h1 <h1
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl" class="mt-1.5 text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
> >
{mix.title} {mix.title}
</h1> </h1>
@@ -79,7 +79,7 @@
</Modal> </Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm"> <Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-bold tracking-tight text-fg"> <h2 class="text-lg font-semibold tracking-tight text-fg">
¿Eliminar «{playlist.name}»? ¿Eliminar «{playlist.name}»?
</h2> </h2>
<p class="mt-2 text-base text-fg-3"> <p class="mt-2 text-base text-fg-3">
@@ -46,7 +46,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p> <p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p>
<h1 <h1
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl" class="mt-1.5 text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
> >
{name} {name}
</h1> </h1>
@@ -141,7 +141,7 @@
> >
<Check class="h-8 w-8" strokeWidth={2.5} /> <Check class="h-8 w-8" strokeWidth={2.5} />
</span> </span>
<h2 class="mt-6 text-2xl font-bold tracking-tight text-fg">¡Subida!</h2> <h2 class="mt-6 text-2xl font-semibold tracking-tight text-fg">¡Subida!</h2>
<p class="mt-3 text-fg-2"> <p class="mt-3 text-fg-2">
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en «{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
unos momentos. unos momentos.
+6 -5
View File
@@ -178,14 +178,14 @@
></div> ></div>
<div class="animate-enter relative flex flex-col gap-2 sm:gap-2.5"> <div class="animate-enter relative flex flex-col gap-2 sm:gap-2.5">
<p class="text-sm tracking-[0.14em] text-fg-2 uppercase sm:text-base">{data.greeting}</p> <p class="text-sm tracking-[0.14em] text-fg-2 uppercase sm:text-base">{data.greeting}</p>
<h1 class="max-w-160 text-3xl leading-[1.05] font-extrabold text-fg sm:text-5xl"> <h1 class="max-w-160 text-3xl leading-[1.05] font-semibold text-fg sm:text-5xl">
Tu música. Sin límites. Tu música. Sin límites.
</h1> </h1>
</div> </div>
</section> </section>
{#if recentlyPlayed.length > 0} {#if recentlyPlayed.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6"> <section class="page-x pt-10 sm:pt-14">
<SectionHeading title="Volver a escuchar" /> <SectionHeading title="Volver a escuchar" />
<MediaGrid min="280px" minMobile="220px"> <MediaGrid min="280px" minMobile="220px">
{#each recentlyPlayed as track, i (track.id)} {#each recentlyPlayed as track, i (track.id)}
@@ -228,7 +228,7 @@
{/if} {/if}
{#if mixes.length > 0 || playlists.length > 0} {#if mixes.length > 0 || playlists.length > 0}
<section class="grid gap-x-8 gap-y-5 page-x pt-5 pb-3 sm:pt-6 lg:grid-cols-2"> <section class="grid gap-x-8 gap-y-10 page-x pt-10 sm:pt-14 lg:grid-cols-2">
{#if mixes.length > 0} {#if mixes.length > 0}
<div class="min-w-0"> <div class="min-w-0">
<SectionHeading title="Hechas para ti" /> <SectionHeading title="Hechas para ti" />
@@ -269,7 +269,7 @@
{/if} {/if}
{#if albums.length > 0} {#if albums.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6"> <section class="page-x pt-10 sm:pt-14">
<SectionHeading title="Álbumes escuchados recientemente"> <SectionHeading title="Álbumes escuchados recientemente">
{#snippet actions()} {#snippet actions()}
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">Ver todos</a> <a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">Ver todos</a>
@@ -290,7 +290,7 @@
</section> </section>
{/if} {/if}
<section class="page-x pt-5 pb-10 sm:pt-6"> <section class="page-x pt-10 pb-16 sm:pt-14">
<SectionHeading title="Descubrir" /> <SectionHeading title="Descubrir" />
{#if latest.length > 0} {#if latest.length > 0}
<MediaGrid as="ul"> <MediaGrid as="ul">
@@ -299,6 +299,7 @@
id={targetId(item)} id={targetId(item)}
title={targetTitle(item)} title={targetTitle(item)}
artist={targetArtist(item)} artist={targetArtist(item)}
coverShape="round"
coverSrc={targetCoverSrc(item)} coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))} hue={hueFor(targetId(item))}
explicit={targetExplicit(item)} explicit={targetExplicit(item)}
+2 -2
View File
@@ -13,10 +13,10 @@
<div class="grid min-h-screen place-items-center bg-bg px-6 text-fg"> <div class="grid min-h-screen place-items-center bg-bg px-6 text-fg">
<div class="flex w-full max-w-110 flex-col items-center gap-9 text-center"> <div class="flex w-full max-w-110 flex-col items-center gap-9 text-center">
<div class="text-3xl font-extrabold tracking-tight">Musify</div> <div class="text-3xl font-semibold tracking-tight">Musify</div>
<div> <div>
<h1 class="text-4xl font-extrabold tracking-tight">Tu música. Sin límites.</h1> <h1 class="text-4xl font-semibold tracking-tight">Tu música. Sin límites.</h1>
<p class="mt-3 text-base text-fg-2">Inicia sesión para continuar.</p> <p class="mt-3 text-base text-fg-2">Inicia sesión para continuar.</p>
</div> </div>