feat: bring over the albums, explore and player work from the worktree

Moves the remaining uncommitted work: the home gets quick mixes and a
"Tus álbumes" shelf, saving a YouTube album now goes through
addYoutubeAlbumToPlaylistAction instead of the SaveYouTubeAlbum command,
and UserAlbum drops its unused ExternalId.

The mixes section keeps its current shape (no refresh action, no badge,
hidden when empty) rather than the older version the worktree carried.
This commit is contained in:
2026-07-21 18:18:23 +02:00
parent b75c97a696
commit 2f98982d83
29 changed files with 1742 additions and 406 deletions
@@ -0,0 +1,112 @@
<script lang="ts" module>
const MENU_WIDTH = 240;
const SUBMENU_WIDTH = 256;
export type AlbumMenuState = { x: number; y: number; openLeft: boolean } & (
{ kind: 'local'; albumId: string } | { kind: 'youtube'; albumId: string }
);
export function albumContextMenuStateFor(
event: MouseEvent,
kind: 'local' | 'youtube',
albumId: string
): AlbumMenuState {
event.preventDefault();
return {
kind,
albumId,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
menu: AlbumMenuState;
playlists: { id: string; name: string }[];
onClose: () => void;
}
let { menu, playlists, onClose }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-30"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<div
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default text-base">Añadir álbum a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu max-sm:w-full"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action={menu.kind === 'youtube'
? '?/addYoutubeAlbumToPlaylist'
: '?/addAlbumToPlaylist'}
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
<input type="hidden" name="albumId" value={menu.albumId} />
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-9 w-9 shrink-0 rounded-control"
/>
<span class="truncate text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{:else}
<p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p>
{/if}
</div>
+2 -2
View File
@@ -81,7 +81,7 @@
class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="px-3 text-xl font-extrabold tracking-wide">Musify</a>
<a href="/" class="px-3 text-xl font-medium tracking-wide">Musify</a>
<div class="relative flex flex-col gap-7" bind:this={navBox}>
<div
@@ -132,7 +132,7 @@
{user.name.charAt(0)}
</span>
{/if}
<span class="truncate leading-[1.35]">{user.name}</span>
<span class="truncate leading-[1.35] text-fg-3">{user.name}</span>
</button>
</div>
</aside>
@@ -11,6 +11,7 @@
typeLabel?: string;
coverSrc?: string | null;
coverSize?: 'small' | 'medium' | 'large';
coverShape?: 'square' | 'round';
hue: number;
explicit?: boolean;
active: boolean;
@@ -28,6 +29,7 @@
typeLabel,
coverSrc,
coverSize = 'large',
coverShape = 'square',
hue,
explicit = false,
active,
@@ -58,7 +60,9 @@
{hue}
size={coverSize}
alt={title}
class="aspect-square w-full rounded-art shadow-art"
class="aspect-square w-full shadow-art {coverShape === 'round'
? 'rounded-art-round'
: 'rounded-art'}"
>
<PlayPauseOverlay {active} {playing} />
</Cover>
@@ -3,8 +3,19 @@
import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
</script>
{#if player.error}
<div
class="fixed inset-x-0 z-40 page-x py-2 md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-player-h))"
>
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
<div
class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))"
@@ -14,6 +14,7 @@
trackIds?: (string | number)[];
coverSrc?: string | null;
index?: number;
onContextMenu?: (event: MouseEvent) => void;
}
let {
@@ -26,7 +27,8 @@
trackCount,
trackIds = [],
coverSrc,
index = 0
index = 0,
onContextMenu
}: Props = $props();
const meta = $derived(
@@ -47,6 +49,7 @@
href={href ?? `/albums/${id}`}
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
oncontextmenu={onContextMenu}
>
{#if coverSrc}
<Cover
@@ -11,6 +11,7 @@
alt?: string;
class?: string;
children?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
}
let {
@@ -20,7 +21,8 @@
src,
alt = '',
class: klass = '',
children
children,
oncontextmenu
}: Props = $props();
let failed = $state(false);
@@ -40,6 +42,8 @@
<div
class="relative overflow-hidden {klass}"
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
role="presentation"
{oncontextmenu}
>
{#if !failed}
<img
@@ -37,7 +37,7 @@
<div class="relative w-full">
{#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
<Icon class="h-4 w-4" />
<Icon class="h-5 w-5" />
</span>
{/if}
<input
@@ -52,7 +52,7 @@
{autocomplete}
{oninput}
bind:value
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
class="w-full rounded-control bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
? 'pr-4 pl-11'
: 'px-4'} {klass}"
/>
@@ -1,10 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
class?: string;
actions?: Snippet;
}
let { title, class: klass = '' }: Props = $props();
let { title, class: klass = '', actions }: Props = $props();
</script>
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2>
{#if actions}
<div class="mb-4 flex items-center justify-between gap-4 sm:mb-6 {klass}">
<h2 class="text-xl font-bold text-fg sm:text-2xl">{title}</h2>
{@render actions()}
</div>
{:else}
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2>
{/if}