Add likes, follows and playlist visibility; redesign web-player UI
Backend: - New Likes feature (toggle/get liked tracks) and endpoints - User follow/unfollow, listening stats, public user profile - Playlist visibility (public/private) support - Various auth and DTO/response adjustments to support the above Frontend (web-player): - New design system pass: theme tokens/mode (light/dark), glass menu, tabs bar, player dock/extras/queue components, mix tiles, artist link - New routes: Liked, public user profile (/u/[id]), likes API proxy - Search refactor: shared SearchBox component used by TopBar and MobileHeader (search now reachable on mobile), live results as you type instead of requiring Enter, dropped the "Buscando…" quick-result preview - Removed now-unused components (MobileNav, CoverBadge, MixBentoTile, PlayPauseOverlay, SuggestionsTable, userDirectory) - Design handoff reference docs added under web-player/design_handoff_musify_player and web-player/docs/design-system-refactor-plan.md
This commit is contained in:
Vendored
+367
-8
@@ -194,6 +194,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/likes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get The Current User'S Liked Tracks. */
|
||||
get: operations["GetLikedTracks"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/likes/toggle": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed. */
|
||||
post: operations["ToggleTrackLike"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/mixes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -556,6 +590,75 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/listening-stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get A User'S Listening Stats For The Current Week And Streak. */
|
||||
get: operations["GetListeningStats"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/profile": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get A User'S Public Profile, Including Follower Counts. */
|
||||
get: operations["GetUserProfile"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/is-following": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Check Whether The Current User Follows Another User. */
|
||||
get: operations["IsFollowingUser"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/follow": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Follow A User. */
|
||||
post: operations["FollowUser"];
|
||||
/** Unfollow A User. */
|
||||
delete: operations["UnfollowUser"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -576,8 +679,7 @@ export interface components {
|
||||
description: null | string;
|
||||
/** Format: int32 */
|
||||
releaseYear: null | number | string;
|
||||
/** Format: int64 */
|
||||
ownerUserId: number | string;
|
||||
ownerUserId: string;
|
||||
/** Format: int32 */
|
||||
trackCount: number | string;
|
||||
smallImageKeyName: null | string;
|
||||
@@ -600,8 +702,7 @@ export interface components {
|
||||
description: null | string;
|
||||
/** Format: int32 */
|
||||
releaseYear: null | number | string;
|
||||
/** Format: int64 */
|
||||
ownerUserId: number | string;
|
||||
ownerUserId: string;
|
||||
/** Format: int32 */
|
||||
trackCount: number | string;
|
||||
smallImageKeyName: null | string;
|
||||
@@ -658,6 +759,7 @@ export interface components {
|
||||
description: null | string;
|
||||
/** Format: uuid */
|
||||
pictureIntentId: null | string;
|
||||
visibility?: components["schemas"]["PlaylistVisibility"];
|
||||
};
|
||||
CreateTrackRequest: {
|
||||
title: string;
|
||||
@@ -684,6 +786,14 @@ export interface components {
|
||||
[key: string]: string[];
|
||||
};
|
||||
};
|
||||
ListeningStatsResponse: {
|
||||
/** Format: int32 */
|
||||
tracksThisWeek: number | string;
|
||||
/** Format: int32 */
|
||||
secondsThisWeek: number | string;
|
||||
/** Format: int32 */
|
||||
streakDays: number | string;
|
||||
};
|
||||
MixApplicationResponse: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -772,6 +882,7 @@ export interface components {
|
||||
smallImageKeyName: null | string;
|
||||
mediumImageKeyName: null | string;
|
||||
largeImageKeyName: null | string;
|
||||
visibility: components["schemas"]["PlaylistVisibility"];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
@@ -790,6 +901,8 @@ export interface components {
|
||||
uploadUrl: string;
|
||||
};
|
||||
/** @enum {unknown} */
|
||||
PlaylistVisibility: "Private" | "Public";
|
||||
/** @enum {unknown} */
|
||||
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
|
||||
RequestAlbumPictureUploadRequest: {
|
||||
fileType: string;
|
||||
@@ -813,6 +926,12 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
expectedAudioSizeBytes?: null | number | string;
|
||||
};
|
||||
ToggleLikeRequest: {
|
||||
/** Format: uuid */
|
||||
trackId: null | string;
|
||||
source: null | components["schemas"]["TrackSource"];
|
||||
externalId: null | string;
|
||||
};
|
||||
TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"];
|
||||
TrackApplicationResponseExternalTrackApplicationResponse: {
|
||||
/** @enum {string} */
|
||||
@@ -835,8 +954,7 @@ export interface components {
|
||||
TrackApplicationResponseLocalTrackApplicationResponse: {
|
||||
/** @enum {string} */
|
||||
source?: "Local";
|
||||
/** Format: int64 */
|
||||
ownerUserId: number | string;
|
||||
ownerUserId: string;
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -910,10 +1028,10 @@ export interface components {
|
||||
newDescription: null | string;
|
||||
/** Format: uuid */
|
||||
newPictureIntentId: null | string;
|
||||
newVisibility?: null | components["schemas"]["PlaylistVisibility"];
|
||||
};
|
||||
UserApplicationResponse: {
|
||||
/** Format: int64 */
|
||||
id: number | string;
|
||||
id: string;
|
||||
name: string;
|
||||
firstName: null | string;
|
||||
secondName: null | string;
|
||||
@@ -923,6 +1041,19 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
UserProfileResponse: {
|
||||
id: string;
|
||||
name: string;
|
||||
firstName: null | string;
|
||||
secondName: null | string;
|
||||
profilePictureUrl: null | string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: int32 */
|
||||
followersCount: number | string;
|
||||
/** Format: int32 */
|
||||
followingCount: number | string;
|
||||
};
|
||||
YouTubeAlbumDetail: {
|
||||
album: components["schemas"]["YouTubeAlbumResult"];
|
||||
description: string;
|
||||
@@ -1465,6 +1596,83 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
GetLikedTracks: {
|
||||
parameters: {
|
||||
query?: {
|
||||
pageNumber?: number | string;
|
||||
pageSize?: number | string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
ToggleTrackLike: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToggleLikeRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": boolean;
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
GetMixes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1633,6 +1841,7 @@ export interface operations {
|
||||
name?: string;
|
||||
pageNumber?: number | string;
|
||||
pageSize?: number | string;
|
||||
onlyPublic?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
@@ -2395,4 +2604,154 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
GetListeningStats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ListeningStatsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
GetUserProfile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserProfileResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IsFollowingUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": boolean;
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
FollowUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UnfollowUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: AlbumMenuState;
|
||||
@@ -55,32 +56,29 @@
|
||||
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;"
|
||||
>
|
||||
<GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onPlayNext}
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Reproducir a continuación</span>
|
||||
<ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
|
||||
<span>Reproducir a continuación</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAddToQueue}
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListEnd class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Añadir a la cola</span>
|
||||
<ListEnd class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
|
||||
<span>Añadir a la cola</span>
|
||||
</button>
|
||||
{#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"
|
||||
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||
>
|
||||
<span class="flex-1 cursor-default text-base">Añadir álbum a una playlist</span>
|
||||
<span class="flex-1 cursor-default">Añadir álbum a una playlist</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
|
||||
</div>
|
||||
<div
|
||||
@@ -108,7 +106,7 @@
|
||||
<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"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
@@ -127,4 +125,4 @@
|
||||
{:else}
|
||||
<p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</GlassMenu>
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import GlassMenu from './ui/GlassMenu.svelte';
|
||||
import SearchBox from './SearchBox.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
accountUrl?: string | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let { user, accountUrl }: Props = $props();
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
@@ -21,61 +29,92 @@
|
||||
</script>
|
||||
|
||||
<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 flex-col gap-2.5 border-b border-hairline bg-bg/85 page-x pt-3 pb-3 backdrop-blur-md sm:hidden"
|
||||
>
|
||||
<a href="/" class="font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
|
||||
<div class="flex h-9 items-center justify-between">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg"
|
||||
>
|
||||
<span
|
||||
class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
|
||||
></span>
|
||||
Musify
|
||||
</a>
|
||||
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
aria-expanded={menuOpen}
|
||||
class="flex items-center rounded-full"
|
||||
>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
|
||||
{:else}
|
||||
<span class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase">
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 mt-2 w-56 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
aria-expanded={menuOpen}
|
||||
class="flex items-center rounded-full"
|
||||
>
|
||||
<MenuItem
|
||||
icon={Folder}
|
||||
label="Canciones subidas"
|
||||
href="/library"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Upload}
|
||||
label="Subir música"
|
||||
href="/upload"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<div class="-mx-1.5 my-1.5 border-t border-line"></div>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Salir" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if menuOpen}
|
||||
<GlassMenu class="absolute top-full right-0 mt-2 w-56 overflow-hidden">
|
||||
<MenuItem
|
||||
icon={User}
|
||||
label="Ver perfil"
|
||||
href="/u/{user.sub}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Folder}
|
||||
label="Canciones subidas"
|
||||
href="/library"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Upload}
|
||||
label="Subir música"
|
||||
href="/upload"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{#if accountUrl}
|
||||
<MenuItem
|
||||
icon={Settings}
|
||||
label="Ajustes"
|
||||
href={accountUrl}
|
||||
target="_blank"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
<MenuItem
|
||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
||||
onclick={() => themeMode.toggle()}
|
||||
/>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
|
||||
</form>
|
||||
</GlassMenu>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
aria-label="Iniciar sesión"
|
||||
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
|
||||
>
|
||||
<LogIn class="h-6 w-6" strokeWidth={2} />
|
||||
</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
aria-label="Iniciar sesión"
|
||||
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
|
||||
>
|
||||
<LogIn class="h-6 w-6" strokeWidth={2} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchBox class="w-full" />
|
||||
</header>
|
||||
|
||||
<svelte:window onclick={onDocumentClick} />
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
const links = $derived([
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Buscar', icon: Search },
|
||||
...(user
|
||||
? [
|
||||
{ href: '/playlists', label: 'Listas', icon: Music },
|
||||
{ href: '/library', label: 'Subidas', icon: Folder }
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-50 border-t border-line bg-bg/95 backdrop-blur-md md:hidden"
|
||||
style="padding-bottom:var(--mf-safe-b)"
|
||||
>
|
||||
<ul class="grid h-[var(--mf-nav-h)]" style="grid-template-columns:repeat({links.length}, 1fr)">
|
||||
{#each links as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex h-full flex-col items-center justify-center gap-1 transition-colors {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-3'}"
|
||||
>
|
||||
<link.icon class="h-[22px] w-[22px] {active ? 'text-accent-soft' : ''}" />
|
||||
<span class="text-[11px] leading-none font-medium">{link.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { class: klass = '', placeholder = 'Canciones, artistas, álbumes o playlists' }: Props =
|
||||
$props();
|
||||
|
||||
let query = $state('');
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function navigateSearch() {
|
||||
const term = query.trim();
|
||||
goto(term ? `/explore?q=${encodeURIComponent(term)}` : '/explore', {
|
||||
replaceState: page.url.pathname === '/explore',
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
const term = query.trim();
|
||||
const onExplore = page.url.pathname === '/explore';
|
||||
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
|
||||
if (term.length === 0 && !onExplore) return;
|
||||
searchTimeout = setTimeout(navigateSearch, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearQuery() {
|
||||
clearTimeout(searchTimeout);
|
||||
query = '';
|
||||
if (page.url.pathname === '/explore') navigateSearch();
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
navigateSearch();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
navigateSearch();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative min-w-0 {klass}">
|
||||
<form onsubmit={onSubmit}>
|
||||
<label
|
||||
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-white/[3.5%] px-3.5 transition focus-within:bg-white/[6%]"
|
||||
>
|
||||
<Search class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||
<input
|
||||
type="text"
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onkeydown={onKeydown}
|
||||
{placeholder}
|
||||
class="w-full min-w-0 bg-transparent text-[13px] text-fg placeholder:text-muted focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearQuery}
|
||||
aria-label="Borrar búsqueda"
|
||||
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,117 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import PlaylistArt from './ui/PlaylistArt.svelte';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import Compass from '@lucide/svelte/icons/compass';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
import Disc from '@lucide/svelte/icons/disc-2';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
|
||||
interface SidebarPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
coverTrackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
playlists: SidebarPlaylist[];
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let { user, playlists }: Props = $props();
|
||||
|
||||
let navBox: HTMLElement | undefined = $state();
|
||||
let pillTop = $state(0);
|
||||
let pillHeight = $state(0);
|
||||
let pillVisible = $state(false);
|
||||
let animate = $state(false);
|
||||
|
||||
const mainLinks = $derived([
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
...(user ? [{ href: '/playlists', label: 'Listas', icon: Music }] : [])
|
||||
]);
|
||||
|
||||
const secondaryLinks = $derived(
|
||||
const navLinks = $derived(
|
||||
user
|
||||
? [
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
||||
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ href: '/liked', label: 'Me gusta', icon: Heart, count: liked.count },
|
||||
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
|
||||
{ href: '/library', label: 'Canciones subidas', icon: Folder },
|
||||
{ href: '/upload', label: 'Subir música', icon: Upload }
|
||||
]
|
||||
: []
|
||||
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
|
||||
);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void page.url.pathname;
|
||||
const box = navBox;
|
||||
if (!box) return;
|
||||
const active = box.querySelector<HTMLElement>('[data-active="true"]');
|
||||
if (!active) {
|
||||
pillVisible = false;
|
||||
return;
|
||||
}
|
||||
const boxRect = box.getBoundingClientRect();
|
||||
const rect = active.getBoundingClientRect();
|
||||
pillTop = rect.top - boxRect.top;
|
||||
pillHeight = rect.height;
|
||||
pillVisible = true;
|
||||
if (!animate) requestAnimationFrame(() => (animate = true));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet navLink(link: { href: string; label: string; icon: typeof Home })}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
data-active={active}
|
||||
class="relative flex items-center gap-3.5 rounded-control px-4 py-2.5 text-sm transition-colors duration-100 {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="h-5 w-5 shrink-0 {active ? 'text-accent-soft' : ''}" />
|
||||
{link.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet groupLabel(label: string)}
|
||||
<p class="mb-4 px-4 text-sm font-semibold tracking-[0.12em] text-fg-2 uppercase">
|
||||
{label}
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
<aside
|
||||
class="sticky top-0 hidden h-screen shrink-0 flex-col border-r border-line bg-bg md:flex"
|
||||
class="hidden shrink-0 flex-col border-r border-hairline bg-[image:var(--mf-sidebar-bg)] pt-5.5 pr-3 pl-5 transition-[background] duration-[600ms] ease-out lg:flex"
|
||||
style="width:var(--mf-sidebar-w)"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-1 flex-col gap-6 overflow-y-auto px-4.5 py-6 md:pt-8"
|
||||
bind:this={navBox}
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-4.5 z-0 overflow-hidden rounded-control border border-line bg-surface {animate
|
||||
? 'transition-[top,height] duration-200 ease-in-out'
|
||||
: ''}"
|
||||
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
|
||||
>
|
||||
<span class="absolute inset-y-0 left-0 w-0.75 rounded-r-full bg-accent-soft"></span>
|
||||
<a href="/" class="flex items-center gap-2.5 px-1.5 pb-6.5">
|
||||
<span class="font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg">Musify</span>
|
||||
</a>
|
||||
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each navLinks as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex items-center gap-3 rounded-[10px] px-3 py-2.25 text-[13.5px] font-medium transition-colors duration-150 {active
|
||||
? 'bg-surface-hover text-fg'
|
||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="h-[17px] w-[17px] shrink-0" strokeWidth={1.6} />
|
||||
<span class="flex-1 truncate">{link.label}</span>
|
||||
{#if link.count}
|
||||
<span class="text-[11px] text-muted tabular-nums">{link.count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if user}
|
||||
<div class="mx-3 mt-6 mb-3.5 h-px bg-white/5"></div>
|
||||
<div class="flex items-center justify-between px-3 pb-1.5">
|
||||
<span class="text-[10.5px] font-semibold tracking-[0.13em] text-muted uppercase"
|
||||
>Tus playlists</span
|
||||
>
|
||||
{#if playlists.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
aria-label="Crear playlist"
|
||||
class="text-base text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{@render groupLabel('Navegación')}
|
||||
<nav class="relative z-10 flex flex-col gap-2">
|
||||
{#each mainLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||
<a
|
||||
href="/playlists/{playlist.id}"
|
||||
class="flex items-center gap-2.75 rounded-[10px] px-3 py-1.75 transition-colors duration-150 {active
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-hover'}"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
version={playlist.updatedAt}
|
||||
size="small"
|
||||
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg opacity-90"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[13.5px] font-medium {active ? 'text-fg' : 'text-fg-2'}">
|
||||
{playlist.name}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
class="flex items-center gap-2 rounded-[10px] px-3 py-2 text-[12px] text-muted transition-colors hover:bg-hover hover:text-fg-2"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
|
||||
Crear tu primera lista
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if secondaryLinks.length > 0}
|
||||
<div>
|
||||
{@render groupLabel('Tu música')}
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
{#each secondaryLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Compass from '@lucide/svelte/icons/compass';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { user, class: klass = '' }: Props = $props();
|
||||
|
||||
const links = $derived(
|
||||
user
|
||||
? [
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
||||
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ href: '/liked', label: 'Me gusta', icon: Heart }
|
||||
]
|
||||
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
|
||||
);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="pointer-events-auto flex items-center justify-around rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] p-1.75 backdrop-blur-[22px] transition-[background] duration-[600ms] lg:hidden {klass}"
|
||||
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
||||
>
|
||||
{#each links as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex flex-col items-center gap-1.25 rounded-xl px-2.5 py-1.5 {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-2'}"
|
||||
>
|
||||
<link.icon class="h-[17px] w-[17px]" strokeWidth={1.6} />
|
||||
<span class="text-[10px] font-medium">{link.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -1,220 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { SessionUser, YouTubeSong } from '$lib/types';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import {
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetCoverSrc,
|
||||
targetExplicit,
|
||||
targetId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import Cover from './ui/Cover.svelte';
|
||||
import ExplicitBadge from './ui/ExplicitBadge.svelte';
|
||||
import GlassMenu from './ui/GlassMenu.svelte';
|
||||
import SearchBox from './SearchBox.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
accountUrl?: string | null;
|
||||
}
|
||||
|
||||
const QUICK_RESULTS_LIMIT = 5;
|
||||
let { user, accountUrl }: Props = $props();
|
||||
|
||||
interface QuickSearchTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
isExplicit?: boolean;
|
||||
}
|
||||
|
||||
interface QuickSearchResponse {
|
||||
items: { track?: QuickSearchTrack | null; youTubeSong?: YouTubeSong | null }[];
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<TrackTarget[]>([]);
|
||||
let resultsOpen = $state(false);
|
||||
let searching = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
let searchRef: HTMLDivElement | undefined = $state();
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let searchToken = 0;
|
||||
|
||||
function clearQuery() {
|
||||
query = '';
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
const term = query.trim();
|
||||
if (term.length < SEARCH_MIN_LENGTH) {
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
return;
|
||||
}
|
||||
resultsOpen = true;
|
||||
searchTimeout = setTimeout(() => runQuickSearch(term), SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function runQuickSearch(term: string) {
|
||||
const token = ++searchToken;
|
||||
searching = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ name: term, pageSize: String(QUICK_RESULTS_LIMIT) });
|
||||
const res = await fetch(`/api/tracks?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = (await res.json()) as QuickSearchResponse;
|
||||
if (token !== searchToken) return;
|
||||
results = data.items
|
||||
.flatMap((item): TrackTarget[] => {
|
||||
if (item.track) return [{ kind: 'local', track: item.track }];
|
||||
if (item.youTubeSong) return [{ kind: 'youtube', song: item.youTubeSong }];
|
||||
return [];
|
||||
})
|
||||
.slice(0, QUICK_RESULTS_LIMIT);
|
||||
} catch {
|
||||
if (token === searchToken) results = [];
|
||||
} finally {
|
||||
if (token === searchToken) searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function playResult(index: number) {
|
||||
player.playOrToggle(results.map(queueItemForTarget), index);
|
||||
resultsOpen = false;
|
||||
}
|
||||
|
||||
function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
resultsOpen = false;
|
||||
const q = query.trim();
|
||||
goto(q ? `/explore?q=${encodeURIComponent(q)}` : '/explore');
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
|
||||
if (searchRef && !searchRef.contains(event.target as Node)) resultsOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-30 hidden grid-cols-[1fr_minmax(0,3fr)_1fr] items-center gap-4 border-b border-line bg-bg/70 px-8 py-4 backdrop-blur-lg md:grid"
|
||||
>
|
||||
<div></div>
|
||||
|
||||
<div class="relative mx-auto w-full max-w-2xl" bind:this={searchRef}>
|
||||
<form onsubmit={onSubmit}>
|
||||
<label
|
||||
class="flex w-full items-center gap-2.5 rounded-full border border-line bg-surface px-4 py-2.5 transition focus-within:border-accent-soft/60"
|
||||
>
|
||||
<Search class="h-4 w-4 shrink-0 text-fg-2" />
|
||||
<input
|
||||
type="text"
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onfocus={() => (resultsOpen = results.length > 0)}
|
||||
placeholder="Buscar"
|
||||
class="w-full bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearQuery}
|
||||
aria-label="Borrar búsqueda"
|
||||
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{#if resultsOpen && (results.length > 0 || searching)}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 left-0 z-20 mt-2 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
>
|
||||
{#if searching && results.length === 0}
|
||||
<p class="px-3 py-2.5 text-sm text-fg-2">Buscando…</p>
|
||||
{:else}
|
||||
{#each results as target, i (targetId(target))}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playResult(i)}
|
||||
class="group/result flex w-full min-w-0 items-center gap-3 rounded-control p-2 text-left transition hover:bg-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-9 w-9 shrink-0 rounded-control shadow-art"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-sm text-fg">{targetTitle(target)}</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => history.back()}
|
||||
aria-label="Atrás"
|
||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-fg-2 transition-colors hover:bg-white/7 hover:text-fg"
|
||||
>
|
||||
<ChevronLeft class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-muted"
|
||||
>
|
||||
<ChevronRight class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-6">
|
||||
<SearchBox class="max-w-[480px] flex-1" />
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="relative shrink-0" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
class="block shrink-0 rounded-full transition active:scale-95"
|
||||
>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm font-semibold text-fg uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 z-20 mt-2 w-40 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
class="block h-8.25 w-8.25 shrink-0 overflow-hidden rounded-full border border-white/10 transition active:scale-95"
|
||||
>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase"
|
||||
>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Salir" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<GlassMenu class="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden">
|
||||
<MenuItem
|
||||
icon={User}
|
||||
label="Ver perfil"
|
||||
href="/u/{user.sub}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{#if accountUrl}
|
||||
<MenuItem
|
||||
icon={Settings}
|
||||
label="Ajustes"
|
||||
href={accountUrl}
|
||||
target="_blank"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
<MenuItem
|
||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
||||
onclick={() => themeMode.toggle()}
|
||||
/>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
|
||||
</form>
|
||||
</GlassMenu>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-on-accent transition hover:brightness-110"
|
||||
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-ink transition hover:brightness-110"
|
||||
>
|
||||
Iniciar sesión
|
||||
</a>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: ContextMenuState;
|
||||
@@ -48,24 +49,21 @@
|
||||
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;"
|
||||
>
|
||||
<GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAddToQueue}
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Reproducir a continuación</span>
|
||||
<ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
|
||||
<span>Reproducir a continuación</span>
|
||||
</button>
|
||||
{#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"
|
||||
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||
>
|
||||
<span class="flex-1 cursor-default text-base">Añadir a una playlist</span>
|
||||
<span class="flex-1 cursor-default">Añadir a una playlist</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
|
||||
</div>
|
||||
<div
|
||||
@@ -99,7 +97,7 @@
|
||||
{/if}
|
||||
<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"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
@@ -116,4 +114,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</GlassMenu>
|
||||
|
||||
@@ -2,44 +2,27 @@
|
||||
import TrackInfo from './TrackInfo.svelte';
|
||||
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';
|
||||
import PlayerExtras from './PlayerExtras.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
|
||||
class="animate-enter pointer-events-auto flex items-center gap-4.5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:hidden"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<TrackInfo compact />
|
||||
</div>
|
||||
{/if}
|
||||
<TransportControls compact />
|
||||
</div>
|
||||
|
||||
<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))"
|
||||
class="animate-enter pointer-events-auto hidden items-center gap-5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:flex"
|
||||
>
|
||||
<div class="absolute inset-x-0 top-0 md:hidden">
|
||||
<SeekBar compact />
|
||||
</div>
|
||||
|
||||
<div class="flex h-full items-center gap-3 page-x md:hidden">
|
||||
<div class="min-w-0 flex-1">
|
||||
<TrackInfo />
|
||||
</div>
|
||||
<TransportControls compact />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden h-full items-center gap-5 px-8 md:grid"
|
||||
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
|
||||
>
|
||||
<div class="w-64 min-w-0 shrink-0">
|
||||
<TrackInfo />
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<SeekBar />
|
||||
</div>
|
||||
<VolumeControl />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<SeekBar />
|
||||
</div>
|
||||
<PlayerExtras />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import TabsBar from '../TabsBar.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
const hasTrack = $derived(player.currentId !== null);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col gap-2.5 px-4 pb-4 lg:left-[var(--mf-sidebar-w)]"
|
||||
>
|
||||
{#if player.error}
|
||||
<div class="pointer-events-auto">
|
||||
<Alert tone="danger">{player.error}</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasTrack}
|
||||
<PlayerBar />
|
||||
{/if}
|
||||
<TabsBar {user} />
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import VolumeControl from './VolumeControl.svelte';
|
||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||
import AlignJustify from '@lucide/svelte/icons/align-justify';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
</script>
|
||||
|
||||
<div class="hidden w-64 shrink-0 items-center justify-end gap-3.5 sm:flex">
|
||||
<span aria-hidden="true" class="text-fg-3">
|
||||
<AlignJustify class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
||||
</span>
|
||||
<VolumeControl />
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => queuePanel.toggle()}
|
||||
aria-pressed={queuePanel.open}
|
||||
aria-label="Cola de reproducción"
|
||||
class="transition-colors hover:text-fg {queuePanel.open ? 'text-fg' : 'text-fg-2'}"
|
||||
>
|
||||
<ListMusic class="h-4 w-4" strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
const upcoming = $derived.by(() => {
|
||||
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
|
||||
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if queuePanel.open}
|
||||
<aside
|
||||
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
|
||||
class="hidden shrink-0 flex-col border-l border-hairline bg-[image:var(--mf-panel-bg)] px-4 pt-5 transition-[background] duration-[600ms] sm:flex"
|
||||
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="font-display text-[14.5px] font-semibold tracking-[-0.02em] text-fg">En cola</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => queuePanel.close()}
|
||||
aria-label="Cerrar cola"
|
||||
class="text-base text-fg-3 transition-colors hover:text-fg"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if player.current.id}
|
||||
<p class="mb-2.75 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase">
|
||||
Reproduciendo
|
||||
</p>
|
||||
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
src={player.current.coverUrl}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-10 w-10 shrink-0 rounded-[9px]"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12.5px] font-semibold text-accent-soft">
|
||||
{player.current.title}
|
||||
</div>
|
||||
<ArtistLink
|
||||
name={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
class="mt-0.75 text-[11px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2.75 flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase"
|
||||
>
|
||||
A continuación
|
||||
</span>
|
||||
{#if upcoming.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.clearUpcoming()}
|
||||
class="shrink-0 text-[11px] text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
Vaciar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
|
||||
{#each upcoming as track, i (track.id)}
|
||||
{@const index = player.tracks.length - upcoming.length + i}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => player.playQueueIndex(index)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
player.playQueueIndex(index);
|
||||
}}
|
||||
class="flex items-center gap-3 rounded-[11px] p-2.25 text-left hover:bg-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={track.id}
|
||||
src={track.coverUrl}
|
||||
size="small"
|
||||
alt={track.title}
|
||||
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[12.5px] font-medium text-fg">{track.title}</div>
|
||||
<ArtistLink
|
||||
name={track.artist || '—'}
|
||||
ownerUserId={track.ownerUserId}
|
||||
class="mt-0.5 text-[11px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
{#if track.duration}
|
||||
<span class="shrink-0 text-[11px] text-muted tabular-nums"
|
||||
>{fmtTime(track.duration)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -27,26 +27,21 @@
|
||||
></div>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="flex w-full max-w-172 items-center gap-3">
|
||||
<span class="text-right text-xs text-muted tabular-nums">
|
||||
<div class="flex w-full max-w-135 items-center gap-2.75">
|
||||
<span class="min-w-8 text-right text-[10.5px] text-fg-3 tabular-nums">
|
||||
{fmtTime(player.progress)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={seek}
|
||||
aria-label="Barra de progreso"
|
||||
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track"
|
||||
class="group relative h-3.5 flex-1 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
|
||||
style="width:{player.progressPercent}%"
|
||||
></div>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-on-art opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-opacity duration-75 group-hover:opacity-100"
|
||||
style="left:{player.progressPercent}%"
|
||||
></span>
|
||||
<div class="h-[3.5px] w-full overflow-hidden rounded-full bg-track">
|
||||
<div class="h-full rounded-full bg-accent" style="width:{player.progressPercent}%"></div>
|
||||
</div>
|
||||
</button>
|
||||
<span class="text-xs text-muted tabular-nums">
|
||||
<span class="min-w-8 text-[10.5px] text-fg-3 tabular-nums">
|
||||
{fmtTime(player.current.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { compact = false }: Props = $props();
|
||||
|
||||
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
|
||||
|
||||
function toggleLike() {
|
||||
if (!player.current.id) return;
|
||||
liked.toggle({
|
||||
id: player.current.id,
|
||||
title: player.current.title,
|
||||
artist: player.current.artist,
|
||||
coverUrl: player.current.coverUrl,
|
||||
source: player.current.source,
|
||||
explicit: player.current.explicit,
|
||||
ownerUserId: player.current.ownerUserId
|
||||
});
|
||||
}
|
||||
</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.25">
|
||||
{#if player.current.id}
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
src={player.current.coverUrl}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-11 w-11 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
||||
class="h-11 w-11 shrink-0 rounded-[11px] shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="h-11 w-11 shrink-0 rounded-lg bg-surface shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
||||
class="h-11 w-11 shrink-0 rounded-[11px] bg-surface shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if player.current.explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-fg">{player.current.title}</span>
|
||||
<span class="truncate text-[13px] font-semibold tracking-[-0.01em] text-fg"
|
||||
>{player.current.title}</span
|
||||
>
|
||||
</div>
|
||||
<div class="truncate text-sm text-muted">{player.current.artist || '—'}</div>
|
||||
<ArtistLink
|
||||
name={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
class="mt-0.5 text-[11.5px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
{#if !compact}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleLike}
|
||||
aria-label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
|
||||
aria-pressed={isLiked}
|
||||
class="shrink-0 transition hover:opacity-80 {isLiked ? 'text-accent' : 'text-fg-3'}"
|
||||
>
|
||||
<Heart class="h-4 w-4" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import Repeat from '@lucide/svelte/icons/repeat';
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
@@ -10,11 +12,23 @@
|
||||
const sideClass = $derived(
|
||||
compact
|
||||
? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90'
|
||||
: 'flex text-fg opacity-70 transition hover:opacity-100 active:scale-90'
|
||||
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-5'}">
|
||||
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-3.5'}">
|
||||
{#if !compact}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.toggleShuffle()}
|
||||
aria-pressed={player.shuffle}
|
||||
aria-label="Aleatorio"
|
||||
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
|
||||
>
|
||||
<Shuffle class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button type="button" onclick={() => player.previous()} aria-label="Anterior" class={sideClass}>
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="2.5" height="14" rx="1" />
|
||||
@@ -26,16 +40,16 @@
|
||||
type="button"
|
||||
onclick={() => player.toggle()}
|
||||
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
|
||||
class="grid h-[36px] w-[36px] shrink-0 place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
|
||||
class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-cta-strong text-ink transition duration-150 hover:brightness-95"
|
||||
>
|
||||
{#if player.playing}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<rect x="1.5" y="0" width="3.5" height="12" rx="1" />
|
||||
<rect x="7" y="0" width="3.5" height="12" rx="1" />
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
|
||||
<rect x="3" y="1" width="3" height="13" rx="1" />
|
||||
<rect x="9" y="1" width="3" height="13" rx="1" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M2.5 0 12 6l-9.5 6z" />
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
|
||||
<path d="M3 1.5v12l10-6z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -46,4 +60,16 @@
|
||||
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if !compact}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.toggleRepeat()}
|
||||
aria-pressed={player.repeat}
|
||||
aria-label="Repetir"
|
||||
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
|
||||
>
|
||||
<Repeat class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import Volume2 from '@lucide/svelte/icons/volume-2';
|
||||
|
||||
function handleVolumeChange(event: Event) {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
const newVolume = Number(target.value);
|
||||
player.setVolume(newVolume);
|
||||
player.setVolume(Number(target.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-end gap-3.5">
|
||||
<div class="flex items-center gap-2.25">
|
||||
<Volume2 class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -17,7 +18,7 @@
|
||||
oninput={handleVolumeChange}
|
||||
aria-label="Volumen"
|
||||
class="mf-volume"
|
||||
style="background:linear-gradient(to right, var(--color-accent-soft) {player.volume}%, var(--color-track) {player.volume}%)"
|
||||
style="background:linear-gradient(to right, var(--mf-text-2) {player.volume}%, var(--mf-track) {player.volume}%)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +26,8 @@
|
||||
.mf-volume {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
width: 84px;
|
||||
height: 3.5px;
|
||||
border-radius: 100px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
@@ -34,17 +35,17 @@
|
||||
.mf-volume::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
background: var(--mf-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-volume::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
background: var(--mf-text);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import Disc3 from '@lucide/svelte/icons/disc-3';
|
||||
import ImageDropzone from './ImageDropzone.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import Field from './Field.svelte';
|
||||
@@ -59,11 +60,18 @@
|
||||
if (result.type === 'success') onSuccess?.();
|
||||
};
|
||||
}}
|
||||
class="mt-4 w-full max-w-2xl space-y-4"
|
||||
class="mt-5 w-full max-w-2xl space-y-5"
|
||||
>
|
||||
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
||||
<Field label="Portada" optional={!requireCover}>
|
||||
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} required={requireCover} />
|
||||
<Field>
|
||||
<ImageDropzone
|
||||
name="cover"
|
||||
fallbackUrl={coverFallbackUrl}
|
||||
required={requireCover}
|
||||
icon={Disc3}
|
||||
gradient
|
||||
class="h-36 w-36 rounded-[16px]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
@@ -78,7 +86,7 @@
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Año de publicación" for="album-year" optional>
|
||||
<Field label="Año de publicación" for="album-year">
|
||||
<Input
|
||||
id="album-year"
|
||||
name="releaseYear"
|
||||
@@ -92,7 +100,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Descripción" for="album-description" optional>
|
||||
<Field label="Descripción" for="album-description">
|
||||
<Textarea
|
||||
id="album-description"
|
||||
name="description"
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
|
||||
const tones = {
|
||||
neutral: 'border-line bg-surface text-fg-2',
|
||||
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
|
||||
danger: 'border-danger/20 bg-danger/10 text-danger-fg'
|
||||
};
|
||||
</script>
|
||||
|
||||
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
|
||||
<p
|
||||
class="rounded-control border px-4 py-3 text-sm shadow-menu backdrop-blur-md {tones[
|
||||
tone
|
||||
]} {klass}"
|
||||
>
|
||||
{@render children()}
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, size = 108 }: Props = $props();
|
||||
let { name, imageUrl, size = 108 }: Props = $props();
|
||||
|
||||
const initials = $derived(
|
||||
name
|
||||
@@ -21,11 +22,15 @@
|
||||
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:shadow-art-lg"
|
||||
style="width:{size}px;height:{size}px"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
|
||||
></div>
|
||||
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
|
||||
{#if imageUrl}
|
||||
<img src={imageUrl} alt="" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
|
||||
></div>
|
||||
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full truncate text-sm text-fg">{name}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name?: string | null;
|
||||
ownerUserId?: string | number | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { name, ownerUserId, class: klass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if name}
|
||||
{#if ownerUserId}
|
||||
<a
|
||||
href="/u/{ownerUserId}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-block max-w-full truncate align-top hover:text-fg-2 hover:underline {klass}"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="block truncate {klass}">{name}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -3,7 +3,7 @@
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
|
||||
variant?: 'primary' | 'secondary' | 'accent' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
type?: 'button' | 'submit';
|
||||
href?: string;
|
||||
@@ -29,20 +29,22 @@
|
||||
}: Props = $props();
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-4 py-2 text-base',
|
||||
md: 'px-5 py-2.5 text-base',
|
||||
lg: 'px-8 py-3.5 text-base'
|
||||
sm: 'px-4 py-2.5 text-[12.5px]',
|
||||
md: 'px-5 py-3 text-[13px]',
|
||||
lg: 'px-6 py-3.5 text-sm'
|
||||
};
|
||||
|
||||
const subtleButton = 'bg-white/[6%] text-fg-2 hover:bg-white/[9%] hover:text-fg';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-accent-soft text-on-accent active:scale-[0.97]',
|
||||
secondary: 'bg-bg text-fg ring-1 ring-line hover:bg-surface',
|
||||
subtle: 'bg-white/10 text-fg hover:bg-accent/18',
|
||||
primary: subtleButton,
|
||||
secondary: subtleButton,
|
||||
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
|
||||
danger: 'bg-danger text-on-accent hover:brightness-110'
|
||||
};
|
||||
|
||||
const classes = $derived(
|
||||
`inline-flex items-center justify-center gap-2 rounded-control font-medium transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
|
||||
`inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<label class="flex items-center gap-3 text-base text-fg-2 {klass}">
|
||||
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
|
||||
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -21,7 +21,7 @@
|
||||
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
||||
/>
|
||||
<Check
|
||||
class="pointer-events-none absolute h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100"
|
||||
class="pointer-events-none absolute h-3.5 w-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { label, class: klass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="pointer-events-none absolute top-2 left-2 rounded-full bg-black/45 px-2 py-0.5 text-[10px] font-semibold tracking-[0.08em] text-white uppercase backdrop-blur-sm {klass}"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
@@ -13,24 +13,19 @@
|
||||
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center rounded-panel border border-dashed border-line px-6 py-16 text-center {klass}"
|
||||
>
|
||||
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
|
||||
{#if Icon}
|
||||
<span
|
||||
class="grid h-16 w-16 place-items-center rounded-full text-on-accent"
|
||||
style="background:radial-gradient(circle at 30% 30%, color-mix(in oklch, var(--color-accent), white 15%), var(--color-accent))"
|
||||
>
|
||||
<Icon class="h-7 w-7" />
|
||||
</span>
|
||||
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.3} />
|
||||
{/if}
|
||||
{#if title}
|
||||
<h3 class="mt-6 font-display text-2xl font-medium tracking-tight text-fg">{title}</h3>
|
||||
<h3 class="mt-6 font-display text-xl font-semibold tracking-[-0.025em] text-fg">
|
||||
{title}
|
||||
</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-2 max-w-sm text-fg-2">{description}</p>
|
||||
<p class="mt-2.5 max-w-sm text-[13px] leading-[1.65] text-fg-2">{description}</p>
|
||||
{/if}
|
||||
{#if actions}
|
||||
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-2.5">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
barWidth?: number;
|
||||
paused?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { size = 13, barWidth = 2, paused = false, class: klass = '' }: Props = $props();
|
||||
|
||||
const delays = [0, 0.3, 0.6];
|
||||
</script>
|
||||
|
||||
<div class="flex items-end justify-center gap-[2px] {klass}" style="height:{size}px">
|
||||
{#each delays as delay (delay)}
|
||||
<span
|
||||
class="rounded-full bg-accent"
|
||||
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -2,25 +2,25 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
label?: string;
|
||||
for?: string;
|
||||
optional?: boolean;
|
||||
class?: string;
|
||||
hint?: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
|
||||
let { label, for: forId, class: klass = '', hint, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col {klass}">
|
||||
<label for={forId} class="mb-2 block text-base font-medium text-fg-2">
|
||||
{label}
|
||||
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
|
||||
</label>
|
||||
{#if label}
|
||||
<label for={forId} class="mb-2 block text-sm font-medium text-fg-2">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
{@render children()}
|
||||
{#if hint}
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
||||
{@render hint()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', style = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="glass-panel animate-pop rounded-panel p-1.5 shadow-menu {klass}" {style}>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
fallbackUrl?: string;
|
||||
required?: boolean;
|
||||
icon?: LucideIcon;
|
||||
gradient?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -12,13 +15,16 @@
|
||||
name,
|
||||
fallbackUrl,
|
||||
required = false,
|
||||
class:
|
||||
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
|
||||
icon: Icon = ImageIcon,
|
||||
gradient = false,
|
||||
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control'
|
||||
}: Props = $props();
|
||||
|
||||
let preview = $state('');
|
||||
let fallbackFailed = $state(false);
|
||||
|
||||
const hasImage = $derived(preview !== '' || (!!fallbackUrl && !fallbackFailed));
|
||||
|
||||
function onInput(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (file) preview = URL.createObjectURL(file);
|
||||
@@ -26,7 +32,10 @@
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}"
|
||||
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
|
||||
? ''
|
||||
: 'border border-line bg-surface hover:border-accent/50'} {klass}"
|
||||
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -48,6 +57,6 @@
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<ImageIcon class="h-12 w-12 text-muted" />
|
||||
<Icon class="h-9 w-9 {gradient ? 'text-white/70' : 'text-muted'}" strokeWidth={1.3} />
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
@@ -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/60 focus:outline-none {Icon
|
||||
class="w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {Icon
|
||||
? 'pr-4 pl-11'
|
||||
: 'px-4'} {klass}"
|
||||
/>
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
{oncontextmenu}
|
||||
>
|
||||
{@render art(mediaCardArt)}
|
||||
<div class="mt-2.5 truncate text-fg transition-colors group-hover:text-accent-soft">{title}</div>
|
||||
<div class="mt-2.5 truncate text-sm font-medium text-fg">{title}</div>
|
||||
{#if subtitle}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">{subtitle}</div>
|
||||
<div class="mt-0.5 truncate text-[12.5px] text-fg-3">{subtitle}</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
href?: string;
|
||||
target?: string;
|
||||
type?: 'button' | 'submit';
|
||||
reload?: boolean;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
@@ -15,6 +16,7 @@
|
||||
icon: Icon,
|
||||
label,
|
||||
href,
|
||||
target,
|
||||
type = 'button',
|
||||
reload = false,
|
||||
onclick,
|
||||
@@ -22,18 +24,25 @@
|
||||
}: Props = $props();
|
||||
|
||||
const classes = $derived(
|
||||
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg-2 transition hover:bg-hover ${klass}`
|
||||
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover ${klass}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} data-sveltekit-reload={reload ? '' : undefined} {onclick} class={classes}>
|
||||
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
|
||||
<a
|
||||
{href}
|
||||
{target}
|
||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||
data-sveltekit-reload={reload ? '' : undefined}
|
||||
{onclick}
|
||||
class={classes}
|
||||
>
|
||||
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
|
||||
{label}
|
||||
</a>
|
||||
{:else}
|
||||
<button {type} {onclick} class={classes}>
|
||||
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
|
||||
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
|
||||
{label}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Cover from './Cover.svelte';
|
||||
import type { Mix } from '$lib/mixes';
|
||||
|
||||
interface Props {
|
||||
mix: Mix;
|
||||
featured?: boolean;
|
||||
banner?: boolean;
|
||||
class?: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { mix, featured = false, banner = false, class: klass = '', index = 0 }: Props = $props();
|
||||
|
||||
const fillCount = $derived(banner ? 40 : 12);
|
||||
|
||||
const tiles = $derived.by(() => {
|
||||
if (mix.items.length === 0) return [];
|
||||
const out: Mix['items'] = [];
|
||||
while (out.length < fillCount) out.push(...mix.items);
|
||||
return out.slice(0, fillCount);
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/mixes/{mix.id}"
|
||||
class="group/tile animate-enter relative block overflow-hidden rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset hover:shadow-art-lg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
|
||||
style="animation-delay:{Math.min(index, 10) * 45}ms"
|
||||
>
|
||||
{#if tiles.length > 0}
|
||||
<div
|
||||
class="absolute inset-0 flex scale-105 transition duration-500 ease-out group-hover/tile:scale-110"
|
||||
>
|
||||
{#each tiles as item, i (i)}
|
||||
<Cover
|
||||
trackId={item.trackId ?? item.videoId ?? ''}
|
||||
src={item.thumbnailUrl}
|
||||
size="medium"
|
||||
class="aspect-square h-full shrink-0"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-linear-to-t from-scrim/85 via-scrim/65 to-scrim/40 {banner
|
||||
? 'sm:bg-linear-to-r'
|
||||
: ''}"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300"
|
||||
></div>
|
||||
<div
|
||||
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
|
||||
? 'sm:flex-row sm:items-center sm:justify-between sm:gap-4'
|
||||
: ''}"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-on-art {featured ? 'text-xl sm:text-2xl' : 'text-base'}">
|
||||
{mix.title}
|
||||
</div>
|
||||
{#if mix.subtitle}
|
||||
<div class="mt-0.5 truncate text-xs text-on-art/75 sm:text-sm">{mix.subtitle}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import MixArt from './MixArt.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queueItemForTarget } from '$lib/tracks';
|
||||
import { mixItemTarget, type Mix } from '$lib/mixes';
|
||||
|
||||
interface Props {
|
||||
mix: Mix;
|
||||
index?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { mix, index = 0, class: klass = '' }: Props = $props();
|
||||
|
||||
function play(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
|
||||
player.playQueue(items, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/mixes/{mix.id}"
|
||||
class="group/tile animate-enter block cursor-pointer {klass}"
|
||||
style="animation-delay:{Math.min(index, 10) * 45}ms"
|
||||
>
|
||||
<div class="relative aspect-square overflow-hidden rounded-art shadow-art">
|
||||
<MixArt
|
||||
items={mix.items}
|
||||
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-black/42"
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={play}
|
||||
aria-label="Reproducir {mix.title}"
|
||||
class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-white/12 bg-ink/76 text-fg opacity-0 backdrop-blur-[8px] transition-opacity duration-150 group-hover/tile:opacity-100"
|
||||
>
|
||||
<Play class="h-3.5 w-3.5" fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3.25 truncate text-[13px] font-semibold tracking-[-0.01em] text-fg">
|
||||
{mix.title}
|
||||
</div>
|
||||
{#if mix.subtitle}
|
||||
<div class="mt-1 truncate text-[11.5px] text-fg-3">{mix.subtitle}</div>
|
||||
{/if}
|
||||
</a>
|
||||
@@ -6,6 +6,7 @@
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
maxWidth?: string;
|
||||
panelClass?: string;
|
||||
@@ -15,9 +16,10 @@
|
||||
let {
|
||||
open,
|
||||
onClose,
|
||||
eyebrow,
|
||||
title,
|
||||
maxWidth = 'max-w-2xl',
|
||||
panelClass = 'border border-line bg-elevated',
|
||||
panelClass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
@@ -74,16 +76,26 @@
|
||||
>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl outline-none sm:p-6 {panelClass}"
|
||||
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl backdrop-blur-[22px] transition-[background] duration-[600ms] outline-none sm:p-6 {panelClass}"
|
||||
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if title}
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold tracking-tight text-fg">{title}</h2>
|
||||
<IconButton label="Cerrar" onclick={onClose}>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
{#if eyebrow}
|
||||
<p class="mb-2.5 text-xs font-semibold tracking-[0.1em] text-muted uppercase">
|
||||
{eyebrow}
|
||||
</p>
|
||||
{/if}
|
||||
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
|
||||
<X class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,14 @@
|
||||
|
||||
let { paused = false }: Props = $props();
|
||||
|
||||
const bars = [
|
||||
{ delay: 0, dur: 0.9 },
|
||||
{ delay: 0.25, dur: 0.7 },
|
||||
{ delay: 0.1, dur: 1.05 },
|
||||
{ delay: 0.4, dur: 0.8 }
|
||||
];
|
||||
const delays = [0, 0.3, 0.6];
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-scrim/45 pb-2">
|
||||
{#each bars as bar, i (i)}
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[3px] bg-scrim/45 pb-2.5">
|
||||
{#each delays as delay (delay)}
|
||||
<span
|
||||
class="w-[3px] origin-bottom rounded-full bg-on-art"
|
||||
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
|
||||
style="height:14px; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="page-x pt-7 pb-8 sm:pt-11 sm:pb-10 {klass}">
|
||||
<section class="page-x pt-3 pb-8 sm:pt-4 sm:pb-10 {klass}">
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
|
||||
<div class="min-w-0">
|
||||
<h1 class="font-display text-2xl font-semibold tracking-tight text-fg sm:text-4xl">{title}</h1>
|
||||
<h1 class="font-display text-[28px] font-semibold tracking-[-0.03em] text-fg sm:text-[32px]">
|
||||
{title}
|
||||
</h1>
|
||||
{#if subtitle}
|
||||
<p class="mt-2 text-sm text-fg-2 sm:text-base">{subtitle}</p>
|
||||
<p class="mt-2 text-[13px] text-fg-2">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if actions}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
let { active, playing }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
|
||||
class:!opacity-100={active}
|
||||
>
|
||||
{#if active && playing}
|
||||
<Pause class="h-5 w-5" fill="currentColor" />
|
||||
{:else}
|
||||
<Play class="h-5 w-5" fill="currentColor" />
|
||||
{/if}
|
||||
</span>
|
||||
@@ -5,16 +5,18 @@
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
trackCount: number;
|
||||
trackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
|
||||
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
|
||||
|
||||
const subtitle = $derived(`${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`);
|
||||
</script>
|
||||
|
||||
<MediaCard href="/playlists/{id}" title={name} subtitle={description} {index}>
|
||||
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
|
||||
{#snippet art(artClass)}
|
||||
<PlaylistArt playlistId={id} {trackIds} version={updatedAt} class={artClass} />
|
||||
{/snippet}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import ImageDropzone from './ImageDropzone.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import Field from './Field.svelte';
|
||||
@@ -8,15 +9,20 @@
|
||||
import Textarea from './Textarea.svelte';
|
||||
import Alert from './Alert.svelte';
|
||||
|
||||
const NAME_MAX_LENGTH = 60;
|
||||
const DESCRIPTION_MAX_LENGTH = 300;
|
||||
|
||||
interface Props {
|
||||
action: string;
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
initialVisibility?: 'private' | 'public';
|
||||
namePlaceholder?: string;
|
||||
coverFallbackUrl?: string;
|
||||
formMessage?: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
helperText?: string;
|
||||
onCancel: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
@@ -25,11 +31,13 @@
|
||||
action,
|
||||
initialName = '',
|
||||
initialDescription = '',
|
||||
namePlaceholder = '',
|
||||
initialVisibility = 'private',
|
||||
namePlaceholder = 'Playlist sin título',
|
||||
coverFallbackUrl,
|
||||
formMessage,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
helperText,
|
||||
onCancel,
|
||||
onSuccess
|
||||
}: Props = $props();
|
||||
@@ -37,6 +45,7 @@
|
||||
let name = $state(untrack(() => initialName));
|
||||
let description = $state(untrack(() => initialDescription));
|
||||
let submitting = $state(false);
|
||||
let visibility = $state(untrack(() => initialVisibility));
|
||||
</script>
|
||||
|
||||
<form
|
||||
@@ -48,49 +57,105 @@
|
||||
return async ({ update, result }) => {
|
||||
await update({ reset: false });
|
||||
submitting = false;
|
||||
if (result.type === 'success') onSuccess?.();
|
||||
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
|
||||
};
|
||||
}}
|
||||
class="mt-4 w-full max-w-4xl space-y-4"
|
||||
class="mt-5 w-full max-w-4xl space-y-5"
|
||||
>
|
||||
<input type="hidden" name="visibility" value={visibility} />
|
||||
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
||||
<Field label="Portada" optional>
|
||||
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
|
||||
<Field>
|
||||
<ImageDropzone
|
||||
name="cover"
|
||||
fallbackUrl={coverFallbackUrl}
|
||||
icon={Music}
|
||||
gradient
|
||||
class="h-36 w-36 rounded-[16px]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
<Field label="Nombre" for="playlist-name">
|
||||
{#snippet hint()}
|
||||
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
||||
{/snippet}
|
||||
<Input
|
||||
id="playlist-name"
|
||||
name="name"
|
||||
maxlength={100}
|
||||
maxlength={NAME_MAX_LENGTH}
|
||||
required
|
||||
bind:value={name}
|
||||
placeholder={namePlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
|
||||
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
|
||||
<Textarea
|
||||
id="playlist-description"
|
||||
name="description"
|
||||
maxlength={300}
|
||||
maxlength={DESCRIPTION_MAX_LENGTH}
|
||||
bind:value={description}
|
||||
placeholder="¿De qué va esta lista?"
|
||||
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
||||
class="flex-1"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-[14px] bg-surface px-4 py-3.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
|
||||
<div class="mt-0.5 text-xs text-muted">
|
||||
{visibility === 'private'
|
||||
? 'Solo tú la ves en tu biblioteca'
|
||||
: 'Cualquiera con el enlace puede verla'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
|
||||
<div
|
||||
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility ===
|
||||
'public'
|
||||
? 'translate-x-full'
|
||||
: ''}"
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (visibility = 'private')}
|
||||
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
|
||||
'private'
|
||||
? 'text-ink'
|
||||
: 'text-muted hover:text-fg-2'}"
|
||||
>
|
||||
Privada
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (visibility = 'public')}
|
||||
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
|
||||
'public'
|
||||
? 'text-ink'
|
||||
: 'text-muted hover:text-fg-2'}"
|
||||
>
|
||||
Pública
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formMessage}
|
||||
<Alert tone="danger">{formMessage}</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||
<Button type="submit" disabled={submitting || name.trim() === ''}>
|
||||
{submitting ? submittingLabel : submitLabel}
|
||||
</Button>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
{#if helperText}
|
||||
<p class="text-sm text-muted">{helperText}</p>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||
<Button type="submit" disabled={submitting || name.trim() === ''}>
|
||||
{submitting ? submittingLabel : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,24 +3,30 @@
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { title, class: klass = '', actions }: Props = $props();
|
||||
let { title, subtitle, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet heading()}
|
||||
<h2 class="font-display text-[17.5px] font-semibold tracking-[-0.025em] text-fg">
|
||||
{title}
|
||||
</h2>
|
||||
{#if subtitle}
|
||||
<p class="mt-1.25 text-xs text-fg-3">{subtitle}</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if actions}
|
||||
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-5 {klass}">
|
||||
<h2 class="font-display text-xl font-medium tracking-tight text-fg sm:text-[1.75rem]">
|
||||
{title}
|
||||
</h2>
|
||||
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
|
||||
<div>{@render heading()}</div>
|
||||
{@render actions()}
|
||||
</div>
|
||||
{:else}
|
||||
<h2
|
||||
class="mb-4 font-display text-xl font-medium tracking-tight text-fg sm:mb-5 sm:text-[1.75rem] {klass}"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div class="mb-4 sm:mb-4.5 {klass}">
|
||||
{@render heading()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
const paddings = { sm: 'p-4', md: 'p-5 sm:p-6', lg: 'p-6 sm:p-12' };
|
||||
</script>
|
||||
|
||||
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
|
||||
<div class="rounded-panel bg-surface transition {paddings[padding]} {klass}">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
bind:value
|
||||
class="min-h-0 w-full resize-none rounded-control border border-line bg-surface px-4 py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {klass}"
|
||||
class="min-h-0 w-full resize-none rounded-control bg-surface px-4 py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {klass}"
|
||||
></textarea>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import NowPlaying from './NowPlaying.svelte';
|
||||
import EqBars from './EqBars.svelte';
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
@@ -16,10 +16,10 @@
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
|
||||
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-muted"
|
||||
class="relative grid h-8 w-8 place-items-center rounded text-xs text-muted tabular-nums"
|
||||
>
|
||||
{#if active}
|
||||
<NowPlaying paused={!playing} />
|
||||
<EqBars paused={!playing} />
|
||||
{:else}
|
||||
<span class="group-hover:hidden">{index + 1}</span>
|
||||
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="truncate text-center text-sm text-muted tabular-nums {klass}">
|
||||
<span class="truncate text-center text-[12.5px] text-muted tabular-nums {klass}">
|
||||
{@render children()}
|
||||
</span>
|
||||
|
||||
@@ -4,19 +4,22 @@
|
||||
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
|
||||
|
||||
interface Props {
|
||||
active?: boolean;
|
||||
class?: string;
|
||||
oncontextmenu?: (event: MouseEvent) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', oncontextmenu, children }: Props = $props();
|
||||
let { active = false, class: klass = '', oncontextmenu, children }: Props = $props();
|
||||
|
||||
const table = getContext<TrackTableContext>(trackTableKey);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group grid track-grid items-center gap-3 rounded-control py-2.5 pr-4 pl-2 transition hover:bg-surface sm:gap-8 sm:pr-6 sm:pl-3 {klass}"
|
||||
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
|
||||
? 'bg-accent-tint'
|
||||
: 'hover:bg-white/[3.2%]'} {klass}"
|
||||
style="--mf-track-cols:{table.columns}; --mf-track-cols-mobile:{table.columnsMobile}"
|
||||
{oncontextmenu}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
const columns = $derived(
|
||||
[
|
||||
index ? '32px' : '',
|
||||
'1fr',
|
||||
'minmax(180px,1fr)',
|
||||
...meta.map((column) => column.width),
|
||||
'64px',
|
||||
action ? '36px' : ''
|
||||
@@ -39,7 +39,7 @@
|
||||
);
|
||||
|
||||
const columnsMobile = $derived(
|
||||
[index ? '28px' : '', '1fr', '52px', action ? '32px' : '']
|
||||
[index ? '28px' : '', 'minmax(120px,1fr)', '52px', action ? '32px' : '']
|
||||
.filter((width) => width !== '')
|
||||
.join(' ')
|
||||
);
|
||||
@@ -54,9 +54,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={klass}>
|
||||
<div class="overflow-x-auto {klass}">
|
||||
<div
|
||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-2 pl-2 text-sm font-medium tracking-wide text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
|
||||
class="grid track-grid items-center gap-3 border-b border-white/5 pr-4 pb-3 pl-2 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
|
||||
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
|
||||
>
|
||||
{#if index}
|
||||
@@ -71,7 +71,7 @@
|
||||
<span></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col">
|
||||
<div class="mt-1 flex flex-col gap-0.5">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
import ExplicitBadge from './ExplicitBadge.svelte';
|
||||
import ArtistLink from './ArtistLink.svelte';
|
||||
|
||||
interface Props {
|
||||
trackId: string | number;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
ownerUserId?: string | number | null;
|
||||
coverSrc?: string | null;
|
||||
coverSize?: string;
|
||||
explicit?: boolean;
|
||||
active?: boolean;
|
||||
titleClass?: string;
|
||||
onClick: () => void;
|
||||
overlay?: Snippet;
|
||||
@@ -20,17 +23,31 @@
|
||||
trackId,
|
||||
title,
|
||||
artist,
|
||||
ownerUserId,
|
||||
coverSrc,
|
||||
coverSize = 'h-[42px] w-[42px]',
|
||||
explicit = false,
|
||||
active = false,
|
||||
titleClass = '',
|
||||
onClick,
|
||||
overlay,
|
||||
badge
|
||||
}: Props = $props();
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={onClick}
|
||||
onkeydown={onKeydown}
|
||||
class="flex min-w-0 items-center gap-3.5 text-left"
|
||||
>
|
||||
<Cover
|
||||
{trackId}
|
||||
src={coverSrc}
|
||||
@@ -45,11 +62,13 @@
|
||||
{#if explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
|
||||
<span
|
||||
class="min-w-0 truncate text-sm font-medium {active
|
||||
? 'text-accent-soft'
|
||||
: 'text-fg'} {titleClass}">{title}</span
|
||||
>
|
||||
{@render badge?.()}
|
||||
</div>
|
||||
{#if artist}
|
||||
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
|
||||
{/if}
|
||||
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-[12.5px] text-fg-3" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,12 +9,13 @@ export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||
export const EXPLORE_ALBUMS_LIMIT = 18;
|
||||
export const HOME_LATEST_PAGE_SIZE = 10;
|
||||
export const HOME_ALBUMS_LIMIT = 12;
|
||||
export const HOME_MIXES_BENTO = 5;
|
||||
export const HOME_MIXES_LIMIT = 6;
|
||||
export const HOME_SHELF_LIMIT = 6;
|
||||
export const HOME_CONTINUE_LIMIT = 6;
|
||||
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||
export const HOME_TOP_MUSIC_FILLER_LIMIT = 10;
|
||||
export const HOME_POPULAR_FILLER_LIMIT = 10;
|
||||
export const HOME_POPULAR_MAX = 5;
|
||||
export const HOME_ALBUM_FILLER_LIMIT = 8;
|
||||
export const HOME_POPULAR_MAX = 8;
|
||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
||||
export const EXPLORE_USERS_LIMIT = 12;
|
||||
|
||||
@@ -5,6 +5,13 @@ export function fmtTime(seconds: number): string {
|
||||
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
|
||||
}
|
||||
|
||||
export function fmtDurationLong(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds || 0));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
return hours > 0 ? `${hours} h ${minutes} min` : `${minutes} min`;
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||
|
||||
export function fmtDate(value: string | number | Date): string {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { toQueueItems, type ApiTrackLike, type TrackSourceKind } from './player.svelte';
|
||||
|
||||
export interface LikedTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string;
|
||||
coverUrl?: string;
|
||||
source?: TrackSourceKind;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
likedAt: number;
|
||||
}
|
||||
|
||||
type LikeToggleInput = {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
coverUrl?: string;
|
||||
source?: TrackSourceKind;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
};
|
||||
|
||||
function toggleRequestBody(track: LikeToggleInput) {
|
||||
return track.source === 'youtube'
|
||||
? { source: 'YouTube' as const, externalId: String(track.id) }
|
||||
: { trackId: String(track.id) };
|
||||
}
|
||||
|
||||
class LikedStore {
|
||||
entries = $state<Record<string, LikedTrack>>({});
|
||||
private hydrated = false;
|
||||
|
||||
count = $derived(Object.keys(this.entries).length);
|
||||
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
||||
|
||||
hydrate(tracks: (ApiTrackLike & { createdAt: string })[]) {
|
||||
if (this.hydrated) return;
|
||||
this.hydrated = true;
|
||||
|
||||
const items = toQueueItems(tracks);
|
||||
const next: Record<string, LikedTrack> = {};
|
||||
items.forEach((item, i) => {
|
||||
next[String(item.id)] = {
|
||||
id: String(item.id),
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
coverUrl: item.coverUrl,
|
||||
source: item.source,
|
||||
explicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId,
|
||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
||||
};
|
||||
});
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
isLiked(id: string | number): boolean {
|
||||
return String(id) in this.entries;
|
||||
}
|
||||
|
||||
async toggle(track: LikeToggleInput) {
|
||||
const id = String(track.id);
|
||||
const before = this.entries;
|
||||
const wasLiked = id in before;
|
||||
const next = { ...before };
|
||||
if (wasLiked) delete next[id];
|
||||
else {
|
||||
next[id] = {
|
||||
id,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
coverUrl: track.coverUrl,
|
||||
source: track.source,
|
||||
explicit: track.explicit,
|
||||
ownerUserId: track.ownerUserId,
|
||||
likedAt: Date.now()
|
||||
};
|
||||
}
|
||||
this.entries = next;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/likes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(toggleRequestBody(track))
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to toggle like');
|
||||
} catch {
|
||||
this.entries = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const liked = new LikedStore();
|
||||
@@ -13,6 +13,7 @@ export interface PlayerTrack {
|
||||
source: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
@@ -28,6 +29,7 @@ export interface QueueItem {
|
||||
source?: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
export interface ApiTrackLike {
|
||||
@@ -38,6 +40,7 @@ export interface ApiTrackLike {
|
||||
externalId?: string | null;
|
||||
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||
isExplicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
function isYouTubeTrack(track: ApiTrackLike): boolean {
|
||||
@@ -69,7 +72,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||
? youTubeThumbnailUrl(track.externalId)
|
||||
: `/api/tracks/${track.id}/cover?size=small`
|
||||
: undefined,
|
||||
explicit: track.isExplicit
|
||||
explicit: track.isExplicit,
|
||||
ownerUserId: youTube ? undefined : track.ownerUserId
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -101,7 +105,8 @@ function toTrack(item: QueueItem): PlayerTrack {
|
||||
duration: 0,
|
||||
explicit: item.explicit,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl
|
||||
coverUrl: item.coverUrl,
|
||||
ownerUserId: item.ownerUserId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +120,8 @@ class PlayerState {
|
||||
error = $state('');
|
||||
recentlyPlayed = $state<PlayerTrack[]>([]);
|
||||
playlists = $state<Playlist[]>([]);
|
||||
shuffle = $state(false);
|
||||
repeat = $state(false);
|
||||
|
||||
accentColor = $state<string | null>(null);
|
||||
|
||||
@@ -154,6 +161,11 @@ class PlayerState {
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
this.#stopProgressLoop();
|
||||
if (this.repeat) {
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
this.next();
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
@@ -397,10 +409,39 @@ class PlayerState {
|
||||
next() {
|
||||
if (this.tracks.length === 0) return;
|
||||
const idx = this.#index();
|
||||
this.currentId = this.tracks[(idx + 1) % this.tracks.length].id;
|
||||
let nextIdx = (idx + 1) % this.tracks.length;
|
||||
if (this.shuffle && this.tracks.length > 1) {
|
||||
do {
|
||||
nextIdx = Math.floor(Math.random() * this.tracks.length);
|
||||
} while (nextIdx === idx);
|
||||
}
|
||||
this.currentId = this.tracks[nextIdx].id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
this.shuffle = !this.shuffle;
|
||||
}
|
||||
|
||||
toggleRepeat() {
|
||||
this.repeat = !this.repeat;
|
||||
}
|
||||
|
||||
playQueueIndex(index: number) {
|
||||
const track = this.tracks[index];
|
||||
if (!track) return;
|
||||
if (this.currentId === track.id) {
|
||||
this.toggle();
|
||||
return;
|
||||
}
|
||||
this.currentId = track.id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
clearUpcoming() {
|
||||
this.tracks = this.currentId === null ? [] : [this.current];
|
||||
}
|
||||
|
||||
previous() {
|
||||
if (this.tracks.length === 0) return;
|
||||
const audio = this.#audioEl();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
let open = $state(false);
|
||||
|
||||
export const queuePanel = {
|
||||
get open() {
|
||||
return open;
|
||||
},
|
||||
toggle() {
|
||||
open = !open;
|
||||
},
|
||||
close() {
|
||||
open = false;
|
||||
},
|
||||
show() {
|
||||
open = true;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
let open = $state(false);
|
||||
|
||||
export const createPlaylistModal = {
|
||||
get open() {
|
||||
return open;
|
||||
},
|
||||
show() {
|
||||
open = true;
|
||||
},
|
||||
close() {
|
||||
open = false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||
|
||||
/**
|
||||
* Merges the in-memory session history (tracks played since the page loaded)
|
||||
* with the server-side listening history, de-duplicated by id and capped at `limit`.
|
||||
*/
|
||||
export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: QueueItem[] = [];
|
||||
|
||||
for (const t of player.recentlyPlayed) {
|
||||
const id = String(t.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({
|
||||
id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
source: t.source,
|
||||
coverUrl: t.coverUrl,
|
||||
explicit: t.explicit,
|
||||
ownerUserId: t.ownerUserId
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of toQueueItems(history)) {
|
||||
const id = String(item.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({
|
||||
id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl,
|
||||
explicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId
|
||||
});
|
||||
}
|
||||
|
||||
return merged.slice(0, limit);
|
||||
}
|
||||
@@ -1,24 +1,107 @@
|
||||
const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [
|
||||
{
|
||||
until: 6,
|
||||
pool: ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música']
|
||||
},
|
||||
{
|
||||
until: 12,
|
||||
pool: ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista']
|
||||
},
|
||||
{ until: 14, pool: ['Buenos días', 'El mediodía sabe mejor con música'] },
|
||||
{
|
||||
until: 19,
|
||||
pool: ['Buenas tardes', 'La tarde pide su banda sonora', 'Un respiro, con algo de música']
|
||||
},
|
||||
{ until: 23, pool: ['Buenas noches', 'Hora de bajar el ritmo', 'Que la noche te acompañe'] },
|
||||
{ until: 24, pool: ['¿Aún despierto?', 'La noche también tiene banda sonora'] }
|
||||
interface SpecialDate {
|
||||
month: number;
|
||||
day: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const SPECIAL_DATES: SpecialDate[] = [
|
||||
{ month: 1, day: 1, message: 'Nuevo año, nueva música.' },
|
||||
{ month: 2, day: 14, message: 'Hoy también hay canciones para eso.' },
|
||||
{ month: 10, day: 31, message: 'Esta noche suena diferente.' },
|
||||
{ month: 12, day: 24, message: 'Nochebuena. Que no pare la música.' },
|
||||
{ month: 12, day: 25, message: 'Feliz Navidad. Pon tu banda sonora.' },
|
||||
{ month: 12, day: 31, message: 'Último día del año. Haz que suene bien.' }
|
||||
];
|
||||
|
||||
export function pickGreeting(hour: number): string {
|
||||
const pool =
|
||||
GREETINGS_BY_HOUR.find((slot) => hour < slot.until)?.pool ?? GREETINGS_BY_HOUR[0].pool;
|
||||
const greeting = pool[Math.floor(Math.random() * pool.length)];
|
||||
return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`;
|
||||
const BIRTHDAY_MESSAGE = 'Hoy toca celebrar. Tú eliges la música.';
|
||||
|
||||
const HOUR_SLOTS = [6, 9, 12, 15, 18, 21, 24];
|
||||
|
||||
const WEEKLY_SCHEDULE: string[][] = [
|
||||
[
|
||||
'Domingo de madrugada.',
|
||||
'Buenos días. Tómate el domingo con calma.',
|
||||
'Domingo. Empieza despacio.',
|
||||
'Una mañana tranquila merece buena música.',
|
||||
'Domingo por la tarde. Hora de bajar el ritmo.',
|
||||
'La semana empieza a despedirse.',
|
||||
'Una última canción antes de volver a empezar.'
|
||||
],
|
||||
[
|
||||
'La semana acaba de empezar.',
|
||||
'Buenos días. Empezamos otra semana.',
|
||||
'Lunes. Dale una banda sonora al día.',
|
||||
'Una pausa musical para este lunes.',
|
||||
'El lunes todavía no ha terminado.',
|
||||
'El lunes ya empieza a quedar atrás.',
|
||||
'Sobrevive al lunes con una última canción.'
|
||||
],
|
||||
[
|
||||
'La noche sigue sonando.',
|
||||
'Buenos días. Martes en marcha.',
|
||||
'La semana ya está cogiendo ritmo.',
|
||||
'Martes al mediodía. ¿Qué escuchamos?',
|
||||
'Una tarde cualquiera necesita música.',
|
||||
'El día empieza a bajar el ritmo.',
|
||||
'Un martes más llega a su fin.'
|
||||
],
|
||||
[
|
||||
'Mitad de semana, mitad de noche.',
|
||||
'Buenos días. Ya es miércoles.',
|
||||
'Ya estamos en el ecuador de la semana.',
|
||||
'Miércoles al mediodía. Un poco de música.',
|
||||
'La semana ya va cuesta abajo.',
|
||||
'Unas canciones para terminar el miércoles.',
|
||||
'Mitad de semana superada.'
|
||||
],
|
||||
[
|
||||
'Una noche más antes del fin de semana.',
|
||||
'Buenos días. Ya casi estamos.',
|
||||
'Jueves. El fin de semana se acerca.',
|
||||
'Una pausa musical antes de seguir.',
|
||||
'Queda poco para desconectar.',
|
||||
'Ya se empieza a notar el viernes.',
|
||||
'Una noche más y llega el fin de semana.'
|
||||
],
|
||||
[
|
||||
'La semana ha terminado. La noche continúa.',
|
||||
'Buenos días. Es viernes.',
|
||||
'Viernes. Hoy suena diferente.',
|
||||
'Ya huele a fin de semana.',
|
||||
'Últimas horas de la semana.',
|
||||
'Se acabó la semana. Dale al play.',
|
||||
'Viernes por la noche. Tú eliges la banda sonora.'
|
||||
],
|
||||
[
|
||||
'La noche es joven. Que siga sonando.',
|
||||
'Buenos días. Hoy no hay prisa.',
|
||||
'Sábado. Empieza el día a tu ritmo.',
|
||||
'Mediodía de sábado. ¿Qué ponemos?',
|
||||
'Una tarde de sábado pide música.',
|
||||
'El sábado todavía tiene mucho por delante.',
|
||||
'Esta noche puede sonar cualquier cosa.'
|
||||
]
|
||||
];
|
||||
|
||||
export interface Birthday {
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `birthday` is optional because the user's date of birth isn't part of the
|
||||
* session/profile data yet. Wire it up here once that field exists upstream.
|
||||
*/
|
||||
export function pickGreeting(date: Date, birthday?: Birthday | null): string {
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
if (birthday && birthday.month === month && birthday.day === day) return BIRTHDAY_MESSAGE;
|
||||
|
||||
const special = SPECIAL_DATES.find((d) => d.month === month && d.day === day);
|
||||
if (special) return special.message;
|
||||
|
||||
const hour = date.getHours();
|
||||
const slot = HOUR_SLOTS.findIndex((until) => hour < until);
|
||||
return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot];
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export interface DirectoryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
const DIRECTORY: DirectoryUser[] = [
|
||||
{ id: 'u1', name: 'Alex Rivera', handle: 'alexrivera' },
|
||||
{ id: 'u2', name: 'Marina Cordero', handle: 'marinac' },
|
||||
{ id: 'u3', name: 'Diego Fuentes', handle: 'dfuentes' },
|
||||
{ id: 'u4', name: 'Lucía Marés', handle: 'luciamares' },
|
||||
{ id: 'u5', name: 'Noah Bennett', handle: 'noahb' },
|
||||
{ id: 'u6', name: 'Sara Ibáñez', handle: 'saraib' },
|
||||
{ id: 'u7', name: 'Kairo', handle: 'kairo' },
|
||||
{ id: 'u8', name: 'Valentina Ortiz', handle: 'valen' }
|
||||
];
|
||||
|
||||
export function searchUsers(query: string, limit = 12): DirectoryUser[] {
|
||||
const term = query.trim().toLowerCase();
|
||||
if (!term) return [];
|
||||
return DIRECTORY.filter(
|
||||
(user) => user.name.toLowerCase().includes(term) || user.handle.toLowerCase().includes(term)
|
||||
).slice(0, limit);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { createApiClient } from '$lib/server/api';
|
||||
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
|
||||
|
||||
const YOUTUBE_FILLER_QUERIES = [
|
||||
export const YOUTUBE_FILLER_QUERIES = [
|
||||
'pop',
|
||||
'rock',
|
||||
'reggaeton',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const ACCENT_LIGHTNESS = 60;
|
||||
export const ACCENT_CHROMA = 0.1;
|
||||
export const ACCENT_LIGHTNESS = 74;
|
||||
export const ACCENT_CHROMA = 0.145;
|
||||
export const ACCENT_HUE = 145;
|
||||
|
||||
export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type ThemeName = 'dark' | 'light';
|
||||
|
||||
const STORAGE_KEY = 'musify.theme';
|
||||
|
||||
function loadInitial(): ThemeName {
|
||||
if (!browser) return 'dark';
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark';
|
||||
} catch {
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeName) {
|
||||
if (!browser) return;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
|
||||
class ThemeModeStore {
|
||||
current = $state<ThemeName>(loadInitial());
|
||||
|
||||
constructor() {
|
||||
applyTheme(this.current);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.current = this.current === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(this.current);
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, this.current);
|
||||
} catch {
|
||||
/* localStorage unavailable (private mode, quota) — preference just won't persist */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const themeMode = new ThemeModeStore();
|
||||
@@ -1,39 +1,150 @@
|
||||
:root {
|
||||
--mf-bg: oklch(12.5% 0.004 95);
|
||||
--mf-elevated: oklch(16% 0.005 95);
|
||||
/* Dynamic tokens: overwritten at runtime from the playing track's hue.
|
||||
These are the fallback values for the default accent (hue 145) so the
|
||||
page never flashes unstyled before hydration. See theme/tokens.ts. */
|
||||
--mf-bg: oklch(0.068 0.008 145);
|
||||
--mf-elevated: oklch(0.11 0.012 145);
|
||||
--mf-sidebar-bg: linear-gradient(180deg, oklch(0.095 0.012 145), oklch(0.068 0.007 145));
|
||||
--mf-panel-bg: oklch(0.09 0.01 145 / 0.28);
|
||||
--mf-bar-bg: oklch(0.105 0.012 145 / 0.9);
|
||||
--mf-hairline: oklch(0.62 0.03 145 / 0.08);
|
||||
--mf-ambient:
|
||||
radial-gradient(52% 38% at 14% 0%, oklch(0.2611 0.0913 145 / 0.26), transparent 54%),
|
||||
radial-gradient(46% 34% at 78% 0%, oklch(0.2347 0.085 145 / 0.2), transparent 56%);
|
||||
--mf-hero-bg: linear-gradient(
|
||||
105deg,
|
||||
oklch(0.2295 0.0838 145 / 0.34) 0%,
|
||||
oklch(0.1558 0.0663 145 / 0.11) 37%,
|
||||
oklch(0.1084 0.055 145 / 0.03) 65%,
|
||||
rgba(6, 6, 9, 0) 92%
|
||||
);
|
||||
--mf-spotlight-bg: linear-gradient(
|
||||
100deg,
|
||||
oklch(0.2189 0.0813 145 / 0.28) 0%,
|
||||
oklch(0.1453 0.0638 145 / 0.1) 40%,
|
||||
oklch(0.1084 0.055 145 / 0.03) 69%,
|
||||
rgba(6, 6, 9, 0) 96%
|
||||
);
|
||||
--mf-modal-glow: linear-gradient(
|
||||
135deg,
|
||||
oklch(0.2295 0.0838 145 / 0.34) 0%,
|
||||
oklch(0.1558 0.0663 145 / 0.11) 28%,
|
||||
oklch(0.1084 0.055 145 / 0.03) 50%,
|
||||
rgba(6, 6, 9, 0) 70%
|
||||
);
|
||||
--mf-logo-grad: linear-gradient(140deg, oklch(0.78 0.15 145), oklch(0.62 0.13 185) 92%);
|
||||
--mf-logo-glow: 0 0 20px oklch(0.74 0.145 145 / 0.3);
|
||||
--mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145));
|
||||
|
||||
--mf-text: oklch(93% 0.004 95);
|
||||
--mf-text-2: oklch(78% 0.006 95);
|
||||
--mf-text-3: oklch(62% 0.007 95);
|
||||
--mf-text-4: oklch(46% 0.006 95);
|
||||
|
||||
--mf-surface: oklch(15.5% 0.005 95);
|
||||
--mf-surface-2: rgba(255, 255, 255, 0.05);
|
||||
--mf-surface-hover: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--mf-border: rgba(255, 255, 255, 0.07);
|
||||
--mf-border-strong: rgba(255, 255, 255, 0.13);
|
||||
--mf-track: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--mf-accent: oklch(60% 0.1 145);
|
||||
--mf-accent-soft: color-mix(in oklch, var(--mf-accent) 80%, transparent);
|
||||
--mf-accent: oklch(0.74 0.145 145);
|
||||
--mf-accent-title: oklch(0.87 0.07 145);
|
||||
--mf-accent-muted: oklch(0.79 0.035 145);
|
||||
--mf-accent-soft-bg: oklch(0.74 0.145 145 / 0.08);
|
||||
--mf-accent-line: oklch(0.74 0.145 145 / 0.2);
|
||||
--mf-accent-hair: oklch(0.74 0.145 145 / 0.09);
|
||||
--mf-accent-btn-bg: oklch(0.74 0.145 145 / 0.15);
|
||||
--mf-accent-btn-bg-hover: oklch(0.74 0.145 145 / 0.24);
|
||||
--mf-accent-glow: color-mix(in oklch, var(--mf-accent) 45%, transparent);
|
||||
--mf-accent-dim: color-mix(in oklch, var(--mf-accent) 22%, transparent);
|
||||
|
||||
/* Fixed neutrals: deliberately hue-less, so only the accent carries color. */
|
||||
--mf-text: #d9d8dc;
|
||||
--mf-text-2: #8a8a8e;
|
||||
--mf-text-3: #7d7d81;
|
||||
--mf-text-4: #646468;
|
||||
|
||||
--mf-surface: rgba(255, 255, 255, 0.022);
|
||||
--mf-surface-2: rgba(255, 255, 255, 0.045);
|
||||
--mf-surface-hover: rgba(255, 255, 255, 0.06);
|
||||
|
||||
--mf-border: rgba(255, 255, 255, 0.04);
|
||||
--mf-border-strong: rgba(255, 255, 255, 0.09);
|
||||
--mf-track: rgba(255, 255, 255, 0.09);
|
||||
|
||||
--mf-scrim: #000000;
|
||||
--mf-on-art: #fefdfb;
|
||||
--mf-ink: #0a0a0d;
|
||||
--mf-cta-strong-bg: #ffffff;
|
||||
|
||||
--mf-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
|
||||
--mf-sidebar-w: 288px;
|
||||
--mf-nav-h: 60px;
|
||||
--mf-player-h: 64px;
|
||||
--mf-sidebar-w: 254px;
|
||||
--mf-queue-w: 304px;
|
||||
--mf-nav-h: 66px;
|
||||
--mf-player-h: 78px;
|
||||
--mf-safe-b: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
@media (width >= 48rem) {
|
||||
/* PlayerBar switches from its compact row to the full layout at `sm` (640px). */
|
||||
@media (width >= 40rem) {
|
||||
:root {
|
||||
--mf-nav-h: 0px;
|
||||
--mf-player-h: 92px;
|
||||
--mf-player-h: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* TabsBar (mobile/tablet nav) is replaced by the Sidebar at `lg` (1024px). */
|
||||
@media (width >= 64rem) {
|
||||
:root {
|
||||
--mf-nav-h: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 73.75rem) {
|
||||
:root {
|
||||
--mf-queue-w: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
/* "Modo blanco" — flips the neutral canvas/surface/text tokens to a light
|
||||
palette. Components that reference these tokens re-theme correctly;
|
||||
components hardcoding literal dark-mode hex colors do not. */
|
||||
:root[data-theme='light'] {
|
||||
--mf-bg: oklch(0.97 0.006 90);
|
||||
--mf-elevated: oklch(0.99 0.003 90);
|
||||
--mf-sidebar-bg: linear-gradient(180deg, oklch(0.985 0.004 90), oklch(0.965 0.006 90));
|
||||
--mf-panel-bg: oklch(0.97 0.006 90 / 0.7);
|
||||
--mf-bar-bg: oklch(0.97 0.006 90 / 0.9);
|
||||
--mf-hairline: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--mf-text: #17171a;
|
||||
--mf-text-2: #4a4a4e;
|
||||
--mf-text-3: #6b6b6f;
|
||||
--mf-text-4: #8c8c90;
|
||||
|
||||
--mf-surface: rgba(0, 0, 0, 0.035);
|
||||
--mf-surface-2: rgba(0, 0, 0, 0.06);
|
||||
--mf-surface-hover: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--mf-border: rgba(0, 0, 0, 0.08);
|
||||
--mf-border-strong: rgba(0, 0, 0, 0.14);
|
||||
--mf-track: rgba(0, 0, 0, 0.12);
|
||||
|
||||
--mf-scrim: #ffffff;
|
||||
--mf-ink: #ffffff;
|
||||
--mf-cta-strong-bg: #17171a;
|
||||
|
||||
--mf-ambient:
|
||||
radial-gradient(52% 38% at 14% 0%, oklch(0.9 0.06 145 / 0.26), transparent 54%),
|
||||
radial-gradient(46% 34% at 78% 0%, oklch(0.9121 0.0497 145 / 0.2), transparent 56%);
|
||||
--mf-hero-bg: linear-gradient(
|
||||
105deg,
|
||||
oklch(0.9145 0.0476 145 / 0.34) 0%,
|
||||
oklch(0.9483 0.0186 145 / 0.11) 37%,
|
||||
oklch(0.97 0 145 / 0.03) 65%,
|
||||
rgba(255, 255, 255, 0) 92%
|
||||
);
|
||||
--mf-spotlight-bg: linear-gradient(
|
||||
100deg,
|
||||
oklch(0.9193 0.0434 145 / 0.28) 0%,
|
||||
oklch(0.9531 0.0145 145 / 0.1) 40%,
|
||||
oklch(0.97 0 145 / 0.03) 69%,
|
||||
rgba(255, 255, 255, 0) 96%
|
||||
);
|
||||
--mf-modal-glow: linear-gradient(
|
||||
135deg,
|
||||
oklch(0.9145 0.0476 145 / 0.34) 0%,
|
||||
oklch(0.9483 0.0186 145 / 0.11) 28%,
|
||||
oklch(0.97 0 145 / 0.03) 50%,
|
||||
rgba(255, 255, 255, 0) 70%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ACCENT_CHROMA, ACCENT_LIGHTNESS } from './color';
|
||||
|
||||
const L = ACCENT_LIGHTNESS / 100;
|
||||
const C = ACCENT_CHROMA;
|
||||
|
||||
function r(value: number, digits = 4): string {
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
function tc(alpha: number, l: number, hue: number): string {
|
||||
const lightness = 0.34 - l / 380;
|
||||
const chroma = Math.max(0, 0.11 - l / 1600);
|
||||
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
|
||||
}
|
||||
|
||||
// Light-mode counterpart of tc(): fades toward the light canvas (~0.97
|
||||
// lightness, 0 chroma at l=88) instead of toward black, so the "glow"
|
||||
// gradients read as a soft tint instead of a dark smudge on a light bg.
|
||||
function tcLight(alpha: number, l: number, hue: number): string {
|
||||
const depth = (88 - l) / 58;
|
||||
const lightness = 0.97 - 0.07 * depth;
|
||||
const chroma = Math.max(0, 0.06 * depth);
|
||||
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
|
||||
}
|
||||
|
||||
export interface ThemeTokens {
|
||||
[cssVar: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every color in the app derives from the hue of the track in playback.
|
||||
* See design_handoff_musify_player/README.md § "Sistema de color dinámico".
|
||||
*
|
||||
* `mode` only affects the neutral canvas tokens (bg/elevated/panel/hairline):
|
||||
* these are re-applied as inline styles on every hue change, so a light-mode
|
||||
* override in theme.css alone can never win against them — the light values
|
||||
* have to come from here instead.
|
||||
*/
|
||||
export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'): ThemeTokens {
|
||||
const h = ((hue % 360) + 360) % 360;
|
||||
const h2 = (h + 40) % 360;
|
||||
const H = r(h, 1);
|
||||
const H2 = r(h2, 1);
|
||||
const light = mode === 'light';
|
||||
|
||||
return {
|
||||
'--mf-accent': `oklch(${r(L)} ${r(C)} ${H})`,
|
||||
'--mf-accent-title': `oklch(0.87 0.07 ${H})`,
|
||||
'--mf-accent-muted': `oklch(0.79 0.035 ${H})`,
|
||||
'--mf-accent-soft-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.08)`,
|
||||
'--mf-accent-line': `oklch(${r(L)} ${r(C)} ${H} / 0.2)`,
|
||||
'--mf-accent-hair': `oklch(${r(L)} ${r(C)} ${H} / 0.09)`,
|
||||
'--mf-accent-btn-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.15)`,
|
||||
'--mf-accent-btn-bg-hover': `oklch(${r(L)} ${r(C)} ${H} / 0.24)`,
|
||||
'--mf-bg': light ? `oklch(0.97 0.006 ${H})` : `oklch(0.068 0.008 ${H})`,
|
||||
'--mf-elevated': light ? `oklch(0.99 0.003 ${H})` : `oklch(0.11 0.012 ${H})`,
|
||||
'--mf-sidebar-bg': light
|
||||
? `linear-gradient(180deg, oklch(0.985 0.004 ${H}), oklch(0.965 0.006 ${H}))`
|
||||
: `linear-gradient(180deg, oklch(0.095 0.012 ${H}), oklch(0.068 0.007 ${H}))`,
|
||||
'--mf-panel-bg': light ? `oklch(0.97 0.006 ${H} / 0.7)` : `oklch(0.09 0.01 ${H} / 0.28)`,
|
||||
'--mf-bar-bg': light ? `oklch(0.97 0.006 ${H} / 0.9)` : `oklch(0.105 0.012 ${H} / 0.9)`,
|
||||
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.1)` : `oklch(0.62 0.03 ${H} / 0.08)`,
|
||||
'--mf-ambient': light
|
||||
? `radial-gradient(52% 38% at 14% 0%, ${tcLight(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tcLight(0.2, 40, h)}, transparent 56%)`
|
||||
: `radial-gradient(52% 38% at 14% 0%, ${tc(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tc(0.2, 40, h)}, transparent 56%)`,
|
||||
'--mf-hero-bg': light
|
||||
? `linear-gradient(105deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 37%, ${tcLight(0.03, 88, h)} 65%, rgba(255,255,255,0) 92%)`
|
||||
: `linear-gradient(105deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 37%, ${tc(0.03, 88, h)} 65%, rgba(6,6,9,0) 92%)`,
|
||||
'--mf-modal-glow': light
|
||||
? `linear-gradient(135deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 28%, ${tcLight(0.03, 88, h)} 50%, rgba(255,255,255,0) 70%)`
|
||||
: `linear-gradient(135deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 28%, ${tc(0.03, 88, h)} 50%, rgba(6,6,9,0) 70%)`,
|
||||
'--mf-spotlight-bg': light
|
||||
? `linear-gradient(100deg, ${tcLight(0.28, 46, h)} 0%, ${tcLight(0.1, 74, h)} 40%, ${tcLight(0.03, 88, h)} 69%, rgba(255,255,255,0) 96%)`
|
||||
: `linear-gradient(100deg, ${tc(0.28, 46, h)} 0%, ${tc(0.1, 74, h)} 40%, ${tc(0.03, 88, h)} 69%, rgba(6,6,9,0) 96%)`,
|
||||
'--mf-logo-grad': `linear-gradient(140deg, oklch(0.78 0.15 ${H}), oklch(0.62 0.13 ${H2}) 92%)`,
|
||||
'--mf-logo-glow': `0 0 20px oklch(${r(L)} ${r(C)} ${H} / 0.3)`,
|
||||
'--mf-cover-grad': `linear-gradient(150deg, oklch(0.5 0.13 ${H}), oklch(0.16 0.035 ${H}))`
|
||||
};
|
||||
}
|
||||
|
||||
export function applyThemeTokens(
|
||||
tokens: ThemeTokens,
|
||||
target: HTMLElement = document.documentElement
|
||||
) {
|
||||
for (const [key, value] of Object.entries(tokens)) target.style.setProperty(key, value);
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export function targetArtist(target: TrackTarget): string | undefined {
|
||||
return (target.kind === 'youtube' ? target.song.artist : target.track.artist) ?? undefined;
|
||||
}
|
||||
|
||||
export function targetOwnerUserId(target: TrackTarget): string | number | undefined {
|
||||
return target.kind === 'youtube' ? undefined : (target.track.ownerUserId ?? undefined);
|
||||
}
|
||||
|
||||
export function targetExplicit(target: TrackTarget): boolean {
|
||||
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
|
||||
}
|
||||
@@ -76,7 +80,8 @@ export function targetForQueueItem(item: QueueItem): TrackTarget {
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: 'Local',
|
||||
isExplicit: item.explicit
|
||||
isExplicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<MediaGrid class="mt-9">
|
||||
<MediaGrid class="mt-7 sm:mt-8">
|
||||
{#each items as album, i (album.id)}
|
||||
<AlbumCard
|
||||
id={album.id}
|
||||
@@ -55,10 +55,7 @@
|
||||
description="Crea el primero y añádele las canciones que hayas subido."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (creating = true)}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Crear álbum
|
||||
</Button>
|
||||
<Button onclick={() => (creating = true)}>Crear álbum</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
||||
(track) => track.source === 'Local' && !inAlbum.has(track.id)
|
||||
);
|
||||
|
||||
const isOwner = user !== null && Number(album.ownerUserId) === Number(user.sub);
|
||||
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
|
||||
|
||||
return { album, tracks, library, isOwner, section: isOwner ? '/albums' : null };
|
||||
};
|
||||
|
||||
@@ -48,17 +48,17 @@
|
||||
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
|
||||
<PlaylistArt
|
||||
{trackIds}
|
||||
{coverUrl}
|
||||
size="large"
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Álbum</p>
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Álbum</p>
|
||||
<h1
|
||||
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
@@ -72,10 +72,10 @@
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
||||
{#if playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@@ -31,18 +31,16 @@
|
||||
|
||||
<TrackTable index action={isOwner} class="mt-6 sm:mt-8">
|
||||
{#each tracks as track, i (track.id)}
|
||||
<TrackRow>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === queueIdForTrack(track)}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
{@const active = player.current.id === queueIdForTrack(track)}
|
||||
<TrackRow {active}>
|
||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
explicit={track.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
/>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
|
||||
@@ -28,12 +28,15 @@
|
||||
<SectionHeading title="Tus canciones subidas" />
|
||||
<TrackTable action>
|
||||
{#each library as track, i (track.id)}
|
||||
<TrackRow>
|
||||
{@const active = player.current.id === track.id}
|
||||
<TrackRow {active}>
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
explicit={track.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
|
||||
+13
-13
@@ -79,21 +79,21 @@
|
||||
<Page>
|
||||
<BackLink href="/explore" label="Volver a explorar" />
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
|
||||
<Cover
|
||||
trackId={album.albumId}
|
||||
src={album.thumbnailUrl}
|
||||
size="large"
|
||||
alt={album.title}
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">
|
||||
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'}
|
||||
</p>
|
||||
<h1
|
||||
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{album.title}
|
||||
</h1>
|
||||
@@ -113,10 +113,10 @@
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -132,19 +132,19 @@
|
||||
|
||||
<TrackTable index class="mt-6 sm:mt-8">
|
||||
{#each tracks as track, i (track.videoId)}
|
||||
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === track.videoId}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
{@const active = player.current.id === track.videoId}
|
||||
<TrackRow
|
||||
{active}
|
||||
oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}
|
||||
>
|
||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
||||
<TrackTitleCell
|
||||
trackId={track.videoId}
|
||||
title={track.title}
|
||||
artist={album.artist}
|
||||
coverSrc={album.thumbnailUrl}
|
||||
explicit={track.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
/>
|
||||
<TrackMeta>{fmtTime(Number(track.durationSeconds))}</TrackMeta>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { YOUTUBE_FILLER_QUERIES } from '$lib/server/youtube';
|
||||
import {
|
||||
addAlbumToPlaylistAction,
|
||||
addTrackAction,
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
import {
|
||||
EXPLORE_ALBUMS_PAGE_SIZE,
|
||||
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
||||
YOUTUBE_FILLER_LIMIT
|
||||
EXPLORE_USERS_LIMIT
|
||||
} from '$lib/config';
|
||||
import { searchUsers } from '$lib/server/userDirectory';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
@@ -39,10 +38,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [tracksRes, playlistsRes, albumsRes] = await Promise.all([
|
||||
const usersPromise = query
|
||||
? api.GET('/users', {
|
||||
params: { query: { usernameSearch: query, pageNumber: 1, pageSize: EXPLORE_USERS_LIMIT } }
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([
|
||||
tracksPromise,
|
||||
playlistsPromise,
|
||||
albumsPromise
|
||||
albumsPromise,
|
||||
usersPromise
|
||||
]);
|
||||
|
||||
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
||||
@@ -54,10 +60,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
tracks,
|
||||
albums: albumItems.flatMap((item) => (item.album ? [item.album] : [])),
|
||||
youtubeAlbums: albumItems.flatMap((item) => (item.youTubeAlbum ? [item.youTubeAlbum] : [])),
|
||||
users: searchUsers(query),
|
||||
youtubeFiller: query
|
||||
? Promise.resolve(null)
|
||||
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
||||
users: usersRes?.data?.items ?? [],
|
||||
genres: YOUTUBE_FILLER_QUERIES,
|
||||
playlists: playlistsRes?.data?.items ?? []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||
import { fmtTime } from '$lib/format';
|
||||
@@ -10,17 +7,16 @@
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
|
||||
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
|
||||
import Rail from '$lib/components/ui/Rail.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
|
||||
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
@@ -42,10 +38,24 @@
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
|
||||
let { data, form } = $props();
|
||||
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
|
||||
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
|
||||
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
|
||||
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
|
||||
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
|
||||
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
|
||||
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
|
||||
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
|
||||
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
|
||||
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
|
||||
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
|
||||
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
|
||||
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
|
||||
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
|
||||
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
|
||||
};
|
||||
|
||||
let searchValue = $state(untrack(() => data.query));
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let { data, form } = $props();
|
||||
|
||||
let ytItems = $state<YouTubeSong[]>([]);
|
||||
let ytUnavailable = $state(false);
|
||||
@@ -71,26 +81,26 @@
|
||||
if (data.query) {
|
||||
ytItems = appendUnique(
|
||||
[],
|
||||
data.tracks.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
|
||||
data.tracks.items.flatMap((item) =>
|
||||
item.youTubeSong ? [item.youTubeSong as YouTubeSong] : []
|
||||
),
|
||||
(i) => i.videoId
|
||||
);
|
||||
ytUnavailable = data.tracks.youtubeUnavailable;
|
||||
return;
|
||||
} else {
|
||||
ytItems = [];
|
||||
ytUnavailable = false;
|
||||
}
|
||||
|
||||
ytItems = [];
|
||||
ytUnavailable = false;
|
||||
|
||||
let cancelled = false;
|
||||
data.youtubeFiller?.then((filler) => {
|
||||
if (cancelled || !filler) return;
|
||||
ytItems = appendUnique([], filler.items, (i) => i.videoId);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const genreTiles = $derived(
|
||||
data.genres.map((query, i) => ({
|
||||
query,
|
||||
hue: Math.round((i * 360) / data.genres.length),
|
||||
...(GENRE_INFO[query] ?? { label: query, tagline: 'Explora este estilo' })
|
||||
}))
|
||||
);
|
||||
|
||||
const songRows = $derived([
|
||||
...localItems.map((track) => ({
|
||||
target: { kind: 'local' as const, track },
|
||||
@@ -126,35 +136,10 @@
|
||||
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers
|
||||
);
|
||||
|
||||
function localAlbumCaption(album: LocalAlbum) {
|
||||
return [
|
||||
album.releaseYear === null ? undefined : String(album.releaseYear),
|
||||
`${Number(album.trackCount)} ${Number(album.trackCount) === 1 ? 'canción' : 'canciones'}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function youtubeAlbumCaption(album: YouTubeAlbum) {
|
||||
return [album.artist, album.releaseYear === null ? undefined : String(album.releaseYear)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function buildHref(query: string) {
|
||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||
}
|
||||
|
||||
function onSearchInput(event: Event) {
|
||||
const value = (event.currentTarget as HTMLInputElement).value;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const term = value.trim();
|
||||
if (term === data.query) return;
|
||||
goto(buildHref(term), { keepFocus: true, replaceState: true, noScroll: true });
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function togglePlay(index: number) {
|
||||
player.playOrToggle(items.map(queueItemForTarget), index);
|
||||
}
|
||||
@@ -224,87 +209,99 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Buscar</title>
|
||||
<meta name="description" content="Busca canciones, álbumes y usuarios en Musify." />
|
||||
<title>{data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Busca canciones, álbumes y personas, o descubre por género en Musify."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader title="Buscar" subtitle="Encuentra canciones, álbumes y personas." />
|
||||
|
||||
<div class="mt-6">
|
||||
<Input
|
||||
type="search"
|
||||
icon={Search}
|
||||
bind:value={searchValue}
|
||||
oninput={onSearchInput}
|
||||
placeholder="¿Qué quieres reproducir?"
|
||||
{#if !data.query}
|
||||
<PageHeader
|
||||
title="Descubrir"
|
||||
subtitle="Encuentra tu próxima canción favorita explorando por género."
|
||||
/>
|
||||
</div>
|
||||
{:else if !nothingFound}
|
||||
<PageHeader title="Resultados para «{data.query}»" />
|
||||
{/if}
|
||||
|
||||
{#if form?.message}
|
||||
<Alert class="mt-4">{form.message}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if nothingFound}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
title="Sin resultados"
|
||||
description="No hay resultados para «{data.query}»."
|
||||
/>
|
||||
<div class="mx-auto max-w-115 py-[8vh] text-center">
|
||||
<h1 class="font-display text-xl font-semibold tracking-[-0.025em] text-fg">
|
||||
Sin resultados para «{data.query}»
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
|
||||
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
|
||||
por playlist.
|
||||
</p>
|
||||
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
|
||||
{#each genreTiles.slice(0, 4) as genre (genre.query)}
|
||||
<a
|
||||
href={buildHref(genre.query)}
|
||||
class="rounded-full border border-white/8 px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-white/5 hover:text-fg"
|
||||
>
|
||||
{genre.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !data.query}
|
||||
<!-- GÉNEROS -->
|
||||
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
||||
{#each genreTiles as genre, i (genre.query)}
|
||||
<a
|
||||
href={buildHref(genre.query)}
|
||||
class="animate-enter group relative block h-28 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-125"
|
||||
style="animation-delay:{Math.min(i, 10) *
|
||||
40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), rgba(9,9,12,.95))"
|
||||
>
|
||||
<div class="font-display text-[15.5px] font-semibold tracking-[-0.02em] text-fg">
|
||||
{genre.label}
|
||||
</div>
|
||||
<div class="mt-1.5 text-[11.5px] text-fg-3">{genre.tagline}</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasAlbums}
|
||||
<div class="mt-8">
|
||||
<div class="mt-9">
|
||||
<SectionHeading title="Álbumes" />
|
||||
<MediaGrid as="ul" min="168px" minMobile="150px">
|
||||
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
|
||||
<li>
|
||||
{#if entry.kind === 'local'}
|
||||
<a
|
||||
href="/albums/{entry.album.id}"
|
||||
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
|
||||
>
|
||||
<div class="relative">
|
||||
<PlaylistArt
|
||||
trackIds={entry.album.coverTrackIds}
|
||||
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{entry.album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">
|
||||
{localAlbumCaption(entry.album)}
|
||||
</div>
|
||||
</a>
|
||||
<AlbumCard
|
||||
id={entry.album.id}
|
||||
title={entry.album.title}
|
||||
releaseYear={entry.album.releaseYear === null
|
||||
? undefined
|
||||
: Number(entry.album.releaseYear)}
|
||||
trackCount={Number(entry.album.trackCount)}
|
||||
trackIds={entry.album.coverTrackIds}
|
||||
index={i}
|
||||
onContextMenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
|
||||
/>
|
||||
{:else}
|
||||
<a
|
||||
<AlbumCard
|
||||
id={entry.album.albumId}
|
||||
href="/albums/external/youtube/{entry.album.albumId}"
|
||||
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
||||
>
|
||||
<div class="relative">
|
||||
<Cover
|
||||
trackId={entry.album.albumId}
|
||||
src={entry.album.thumbnailUrl}
|
||||
size="large"
|
||||
alt={entry.album.title}
|
||||
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{entry.album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">
|
||||
{youtubeAlbumCaption(entry.album)}
|
||||
</div>
|
||||
</a>
|
||||
title={entry.album.title}
|
||||
subtitle={entry.album.artist}
|
||||
releaseYear={entry.album.releaseYear === null
|
||||
? undefined
|
||||
: Number(entry.album.releaseYear)}
|
||||
coverSrc={entry.album.thumbnailUrl}
|
||||
index={i}
|
||||
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
@@ -313,71 +310,78 @@
|
||||
{/if}
|
||||
|
||||
{#if hasUsers}
|
||||
<div class="mt-8">
|
||||
<div class="mt-9">
|
||||
<SectionHeading title="Usuarios" />
|
||||
<Rail>
|
||||
{#each users as u, i (u.id)}
|
||||
<div
|
||||
<a
|
||||
href="/u/{u.id}"
|
||||
class="group/artist animate-enter shrink-0"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
>
|
||||
<ArtistAvatar name={u.name} size={96} />
|
||||
<div class="mt-1 w-24 truncate text-center text-xs text-fg-3">@{u.handle}</div>
|
||||
</div>
|
||||
<ArtistAvatar name={u.name} imageUrl={u.profilePictureUrl} size={96} />
|
||||
</a>
|
||||
{/each}
|
||||
</Rail>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6">
|
||||
{#if songRows.length > 0}
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each songRows as { target: item, seconds }, i (targetId(item))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => togglePlay(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
||||
aria-label="Reproducir {targetTitle(item)}"
|
||||
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(item)}
|
||||
src={targetCoverSrc(item)}
|
||||
size="small"
|
||||
alt={targetTitle(item)}
|
||||
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
||||
{#if data.query}
|
||||
<div class="mt-9">
|
||||
{#if songRows.length > 0}
|
||||
<SectionHeading title="Canciones" />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each songRows as { target: item, seconds }, i (targetId(item))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => togglePlay(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
||||
aria-label="Reproducir {targetTitle(item)}"
|
||||
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
|
||||
>
|
||||
{#if isTargetCurrent(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(item)}
|
||||
<ExplicitBadge />
|
||||
<Cover
|
||||
trackId={targetId(item)}
|
||||
src={targetCoverSrc(item)}
|
||||
size="small"
|
||||
alt={targetTitle(item)}
|
||||
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
||||
>
|
||||
{#if isTargetCurrent(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(item)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-[13px] font-medium text-fg">
|
||||
{targetTitle(item)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(item)}
|
||||
<div class="truncate text-[11.5px] text-fg-3">
|
||||
Canción · {targetArtist(item)}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="truncate text-fg transition-colors group-hover/row:text-accent-soft">
|
||||
{targetTitle(item)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(item)}
|
||||
<div class="truncate text-sm text-fg-3">Canción · {targetArtist(item)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="shrink-0 text-sm text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<span class="shrink-0 text-[11.5px] text-muted tabular-nums"
|
||||
>{fmtTime(seconds)}</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if hasMore}
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{#if hasMore}
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{/if}
|
||||
{:else if !ytUnavailable && !nothingFound}
|
||||
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
|
||||
{/if}
|
||||
{:else if data.query && !ytUnavailable}
|
||||
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ytUnavailable}
|
||||
<EmptyState
|
||||
|
||||
@@ -51,20 +51,28 @@
|
||||
index
|
||||
action
|
||||
meta={[
|
||||
{ label: 'Álbum', width: '160px' },
|
||||
{ label: 'Subida', width: '100px' },
|
||||
{ label: 'Escuchas', width: '100px' }
|
||||
]}
|
||||
class="mt-6 sm:mt-8"
|
||||
class="mt-7 sm:mt-8"
|
||||
>
|
||||
{#each items as track, i (track.id)}
|
||||
<TrackRow>
|
||||
{@const active = player.current.id === track.id}
|
||||
<TrackRow {active}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === track.id}
|
||||
{active}
|
||||
playing={player.playing}
|
||||
onToggle={() => togglePlay(i)}
|
||||
/>
|
||||
<TrackTitleCell trackId={track.id} title={track.title} onClick={() => togglePlay(i)} />
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
{active}
|
||||
onClick={() => togglePlay(i)}
|
||||
/>
|
||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
||||
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
if (!locals.user) redirect(302, '/explore');
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
||||
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
|
||||
const tracks = $derived(liked.list);
|
||||
const queueItems = $derived<QueueItem[]>(
|
||||
tracks.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
source: t.source,
|
||||
coverUrl: t.coverUrl,
|
||||
explicit: t.explicit
|
||||
}))
|
||||
);
|
||||
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(queueItems, index);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (queueItems.length === 0) return;
|
||||
if (isCurrentQueue) player.toggle();
|
||||
else player.playQueue(queueItems, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Me gusta</title>
|
||||
<meta name="description" content="Las canciones que te gustan en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
|
||||
<div
|
||||
class="grid h-36 w-36 shrink-0 place-items-center rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
style="background:linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10))"
|
||||
>
|
||||
<Heart class="h-10 w-10 text-white/90" fill="currentColor" strokeWidth={0} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Colección</p>
|
||||
<h1
|
||||
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] text-fg sm:text-[33px]"
|
||||
>
|
||||
Me gusta
|
||||
</h1>
|
||||
<p class="mt-3.25 text-[12.5px] text-fg-2">
|
||||
{tracks.length}
|
||||
{tracks.length === 1 ? 'canción' : 'canciones'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if tracks.length > 0}
|
||||
<TrackTable
|
||||
index
|
||||
action
|
||||
meta={[
|
||||
{ label: 'Álbum', width: '160px' },
|
||||
{ label: 'Añadida', width: '140px' }
|
||||
]}
|
||||
class="mt-6 sm:mt-8"
|
||||
>
|
||||
{#each tracks as track, i (track.id)}
|
||||
{@const active = player.current.id === track.id}
|
||||
<TrackRow {active}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
{active}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
coverSrc={track.coverUrl}
|
||||
explicit={track.explicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
/>
|
||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||
<TrackMeta class="hidden sm:block">{fmtDate(track.likedAt)}</TrackMeta>
|
||||
<TrackMeta>—</TrackMeta>
|
||||
<IconButton
|
||||
label="Quitar de Me gusta"
|
||||
size="sm"
|
||||
revealOnHover
|
||||
onclick={() => liked.toggle(track)}
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</TrackTable>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Heart}
|
||||
title="Todavía no tienes canciones con me gusta"
|
||||
description="Toca el corazón en cualquier canción del reproductor para guardarla aquí."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
@@ -67,12 +67,15 @@
|
||||
<Page>
|
||||
<BackLink href="/" label="Volver al inicio" />
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<MixArt {items} class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44" />
|
||||
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
|
||||
<MixArt
|
||||
{items}
|
||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Mezcla</p>
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Mezcla</p>
|
||||
<h1
|
||||
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{mix.title}
|
||||
</h1>
|
||||
@@ -89,10 +92,10 @@
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -108,19 +111,16 @@
|
||||
|
||||
<TrackTable index class="mt-6 sm:mt-8">
|
||||
{#each items as item, i (mixItemKey(item))}
|
||||
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={isTargetCurrent(targets[i])}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
{@const active = isTargetCurrent(targets[i])}
|
||||
<TrackRow {active} oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
||||
<TrackTitleCell
|
||||
trackId={targetId(targets[i])}
|
||||
title={targetTitle(targets[i])}
|
||||
artist={targetArtist(targets[i])}
|
||||
coverSrc={targetCoverSrc(targets[i])}
|
||||
explicit={item.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
/>
|
||||
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
|
||||
|
||||
@@ -19,7 +19,30 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
|
||||
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
|
||||
|
||||
return { playlists: data };
|
||||
const tracksByPlaylist = await Promise.all(
|
||||
data.items.map((playlist) =>
|
||||
api
|
||||
.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
|
||||
})
|
||||
.then((res) => res.data?.items ?? [])
|
||||
)
|
||||
);
|
||||
|
||||
const items = data.items.map((playlist, i) => {
|
||||
const tracks = tracksByPlaylist[i];
|
||||
const trackCount = tracks.length;
|
||||
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
|
||||
return { ...playlist, trackCount, durationSeconds };
|
||||
});
|
||||
|
||||
const totals = {
|
||||
playlistCount: items.length,
|
||||
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
|
||||
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
|
||||
};
|
||||
|
||||
return { playlists: { ...data, items }, totals };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -30,6 +53,7 @@ export const actions: Actions = {
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
|
||||
const cover = form.get('cover');
|
||||
|
||||
if (name === '' || name.length > MAX_NAME) {
|
||||
@@ -55,7 +79,7 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
const { data, error: err } = await api.POST('/playlists', {
|
||||
body: { name, description, pictureIntentId }
|
||||
body: { name, description, pictureIntentId, visibility }
|
||||
});
|
||||
|
||||
if (err || !data)
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
<script lang="ts">
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import { fmtDurationLong } from '$lib/format';
|
||||
|
||||
const playlistNames = [
|
||||
'Mi Playlist',
|
||||
'Tus favoritos',
|
||||
'Lo mejor de hoy',
|
||||
'Para relajarse',
|
||||
'Buena vibra',
|
||||
'Modo estudio',
|
||||
'Road Trip',
|
||||
'Domingos',
|
||||
'Late Night',
|
||||
'Descubriendo música',
|
||||
'Todo un poco',
|
||||
'En bucle',
|
||||
'Mi colección',
|
||||
'Canciones para repetir',
|
||||
'Hits del momento'
|
||||
];
|
||||
const namePlaceholder = playlistNames[Math.floor(Math.random() * playlistNames.length)];
|
||||
|
||||
let { data, form } = $props();
|
||||
let { data } = $props();
|
||||
|
||||
const items = $derived(data.playlists.items);
|
||||
|
||||
let creating = $state(false);
|
||||
|
||||
function openCreate() {
|
||||
creating = true;
|
||||
}
|
||||
const totals = $derived(data.totals);
|
||||
const summary = $derived(
|
||||
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'playlist' : 'playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'canción' : 'canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -46,24 +24,24 @@
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader title="Mis listas" subtitle="Crea y organiza tus colecciones.">
|
||||
<PageHeader
|
||||
title="Mis listas"
|
||||
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2.5} />
|
||||
Crear lista
|
||||
</Button>
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<MediaGrid class="mt-9">
|
||||
<MediaGrid min="210px" minMobile="180px" class="mt-7 sm:mt-8">
|
||||
{#each items as playlist, i (playlist.id)}
|
||||
<PlaylistCard
|
||||
id={playlist.id}
|
||||
name={playlist.name}
|
||||
description={playlist.description}
|
||||
trackCount={playlist.trackCount}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
updatedAt={playlist.updatedAt}
|
||||
index={i}
|
||||
@@ -77,22 +55,8 @@
|
||||
description="Crea la primera y añádele canciones de tu biblioteca."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Crear lista
|
||||
</Button>
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
<Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
|
||||
<PlaylistForm
|
||||
action="?/create"
|
||||
{namePlaceholder}
|
||||
formMessage={form?.message}
|
||||
submitLabel="Crear"
|
||||
submittingLabel="Creando…"
|
||||
onCancel={() => (creating = false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -8,75 +8,28 @@ import {
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { shuffle } from '$lib/collections';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
const MAX_NAME = 100;
|
||||
const SUGGESTIONS_LIMIT = 20;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
||||
optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([
|
||||
const [playlistRes, tracksRes] = await Promise.all([
|
||||
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
|
||||
}),
|
||||
user
|
||||
? api.GET('/tracks/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null)
|
||||
})
|
||||
]);
|
||||
|
||||
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
|
||||
|
||||
const tracks = tracksRes.data?.items ?? [];
|
||||
const inPlaylist = new Set(tracks.map((t) => t.id));
|
||||
const library = (libraryRes?.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
|
||||
|
||||
const alreadyLinked = new Set(
|
||||
tracks.flatMap((t) => (t.source === 'YouTube' && t.externalId ? [t.externalId] : []))
|
||||
);
|
||||
const seeds = [...new Set(tracks.map((t) => t.artist).filter((a): a is string => !!a))];
|
||||
const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined;
|
||||
|
||||
const youtube = fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
SUGGESTIONS_LIMIT + alreadyLinked.size,
|
||||
seed
|
||||
)
|
||||
.then((filler) => ({
|
||||
query: filler.query,
|
||||
items: (filler.items as YouTubeSong[])
|
||||
.filter((song) => !alreadyLinked.has(song.videoId))
|
||||
.slice(0, SUGGESTIONS_LIMIT)
|
||||
}))
|
||||
.catch(() => ({ query: '', items: [] as YouTubeSong[] }));
|
||||
|
||||
return {
|
||||
playlist,
|
||||
tracks,
|
||||
library,
|
||||
youtube,
|
||||
playlists: playlistsRes?.data?.items ?? []
|
||||
};
|
||||
return { playlist, tracks };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
|
||||
removeTrack: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
@@ -98,6 +51,7 @@ export const actions: Actions = {
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
|
||||
const cover = form.get('cover');
|
||||
if (name === '' || name.length > MAX_NAME) {
|
||||
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
|
||||
@@ -119,7 +73,7 @@ export const actions: Actions = {
|
||||
|
||||
const result = await api.PUT('/playlists/{playlistId}', {
|
||||
params: { path: { playlistId: params.id } },
|
||||
body: { newName: name, newDescription: description, newPictureIntentId }
|
||||
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
|
||||
if (failure) return failure;
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
<script lang="ts">
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import PlaylistHeader from './components/PlaylistHeader.svelte';
|
||||
import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
|
||||
import SuggestionsTable from './components/SuggestionsTable.svelte';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const playlist = $derived(data.playlist);
|
||||
const tracks = $derived(data.tracks);
|
||||
const library = $derived(data.library);
|
||||
|
||||
let editing = $state(false);
|
||||
let confirmingDelete = $state(false);
|
||||
let youtubeSuggestions = $state<YouTubeSong[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
youtubeSuggestions = [];
|
||||
data.youtube.then((result) => {
|
||||
youtubeSuggestions = result.items;
|
||||
});
|
||||
});
|
||||
|
||||
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
|
||||
|
||||
@@ -47,6 +38,7 @@
|
||||
description={playlist.description}
|
||||
trackIds={tracks.map((t) => t.id)}
|
||||
updatedAt={playlist.updatedAt}
|
||||
visibility={playlist.visibility}
|
||||
playing={isCurrentQueue && player.playing}
|
||||
onPlayAll={playAll}
|
||||
onEdit={() => (editing = true)}
|
||||
@@ -55,10 +47,17 @@
|
||||
|
||||
{#if tracks.length > 0}
|
||||
<PlaylistTrackTable {tracks} />
|
||||
{/if}
|
||||
|
||||
{#if library.length > 0 || youtubeSuggestions.length > 0}
|
||||
<SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Esta playlist todavía está vacía"
|
||||
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
|
||||
class="mt-6 sm:mt-8"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
@@ -67,6 +66,7 @@
|
||||
action="?/rename"
|
||||
initialName={playlist.name}
|
||||
initialDescription={playlist.description ?? ''}
|
||||
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
|
||||
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
@@ -79,10 +79,10 @@
|
||||
</Modal>
|
||||
|
||||
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
|
||||
<h2 class="text-lg font-semibold tracking-tight text-fg">
|
||||
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
||||
¿Eliminar «{playlist.name}»?
|
||||
</h2>
|
||||
<p class="mt-2 text-base text-fg-3">
|
||||
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
|
||||
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
|
||||
dispositivos donde la tengas guardada.
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -13,39 +12,50 @@
|
||||
description?: string | null;
|
||||
trackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
visibility: 'Public' | 'Private';
|
||||
playing: boolean;
|
||||
onPlayAll: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { id, name, description, trackIds, updatedAt, playing, onPlayAll, onEdit, onDelete }: Props =
|
||||
$props();
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
trackIds,
|
||||
updatedAt,
|
||||
visibility,
|
||||
playing,
|
||||
onPlayAll,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<BackLink href="/playlists" label="Volver a tus listas" />
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
|
||||
<PlaylistArt
|
||||
playlistId={id}
|
||||
{trackIds}
|
||||
size="large"
|
||||
version={updatedAt}
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p>
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Lista</p>
|
||||
<h1
|
||||
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{name}
|
||||
</h1>
|
||||
{#if description}
|
||||
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
|
||||
{/if}
|
||||
<p class="mt-3 text-sm text-muted">
|
||||
<p class="mt-3.25 text-[12.5px] text-fg-2">
|
||||
{trackIds.length}
|
||||
{trackIds.length === 1 ? 'canción' : 'canciones'}
|
||||
{trackIds.length === 1 ? 'canción' : 'canciones'} · {visibility === 'Public'
|
||||
? 'Pública'
|
||||
: 'Privada'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,19 +63,15 @@
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
||||
{#if playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={onEdit}>
|
||||
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
|
||||
Editar
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={onDelete}>
|
||||
<Trash class="h-4 w-4" strokeWidth={1.5} />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -31,23 +31,29 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<TrackTable index action meta={[{ label: 'Añadida', width: '140px' }]} class="mt-6 sm:mt-8">
|
||||
<TrackTable
|
||||
index
|
||||
action
|
||||
meta={[
|
||||
{ label: 'Álbum', width: '160px' },
|
||||
{ label: 'Añadida', width: '140px' }
|
||||
]}
|
||||
class="mt-6 sm:mt-8"
|
||||
>
|
||||
{#each tracks as track, i (track.id)}
|
||||
<TrackRow>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === queueIdForTrack(track)}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
{@const active = player.current.id === queueIdForTrack(track)}
|
||||
<TrackRow {active}>
|
||||
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
coverSrc={isPendingYouTubeTrack(track) && track.externalId
|
||||
? youTubeThumbnailUrl(track.externalId)
|
||||
: undefined}
|
||||
explicit={track.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet badge()}
|
||||
@@ -58,6 +64,7 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TrackTitleCell>
|
||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
<form
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import {
|
||||
player,
|
||||
queueIdForTrack,
|
||||
type ApiTrackLike,
|
||||
type QueueItem
|
||||
} from '$lib/player/player.svelte';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
|
||||
type LibraryTrack = ApiTrackLike & { duration: number | string };
|
||||
type Suggestion = { kind: 'local'; track: LibraryTrack } | { kind: 'youtube'; song: YouTubeSong };
|
||||
|
||||
interface Props {
|
||||
library: LibraryTrack[];
|
||||
youtube: YouTubeSong[];
|
||||
playlists: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
let { library, youtube, playlists }: Props = $props();
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
|
||||
function openContextMenu(event: MouseEvent, item: Suggestion) {
|
||||
contextMenu = contextMenuStateFor(event, item);
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function addToQueue() {
|
||||
if (!contextMenu) return;
|
||||
player.addToQueue(queueItemFor(contextMenu as Suggestion));
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
const suggestions = $derived<Suggestion[]>([
|
||||
...library.map((track) => ({ kind: 'local' as const, track })),
|
||||
...youtube.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
]);
|
||||
|
||||
function idFor(item: Suggestion) {
|
||||
return item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track);
|
||||
}
|
||||
|
||||
function queueItemFor(item: Suggestion): QueueItem {
|
||||
return item.kind === 'youtube'
|
||||
? {
|
||||
id: item.song.videoId,
|
||||
title: item.song.title,
|
||||
artist: item.song.artist,
|
||||
source: 'youtube',
|
||||
coverUrl: item.song.thumbnailUrl,
|
||||
explicit: item.song.isExplicit
|
||||
}
|
||||
: {
|
||||
id: queueIdForTrack(item.track),
|
||||
title: item.track.title,
|
||||
artist: item.track.artist ?? undefined,
|
||||
source: 'local',
|
||||
coverUrl: `/api/tracks/${item.track.id}/cover?size=small`
|
||||
};
|
||||
}
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(suggestions.map(queueItemFor), index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-10 sm:mt-12">
|
||||
<SectionHeading title="Sugerencias" />
|
||||
<TrackTable action>
|
||||
{#each suggestions as item, i (idFor(item))}
|
||||
<TrackRow oncontextmenu={(e) => openContextMenu(e, item)}>
|
||||
<TrackTitleCell
|
||||
trackId={item.kind === 'youtube' ? item.song.videoId : item.track.id}
|
||||
title={item.kind === 'youtube' ? item.song.title : item.track.title}
|
||||
artist={item.kind === 'youtube' ? item.song.artist : item.track.artist}
|
||||
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
|
||||
explicit={item.kind === 'youtube' && item.song.isExplicit}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
{#if player.current.id === idFor(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TrackTitleCell>
|
||||
<TrackMeta>
|
||||
{fmtTime(
|
||||
item.kind === 'youtube' ? item.song.durationSeconds : Number(item.track.duration)
|
||||
)}
|
||||
</TrackMeta>
|
||||
{#if item.kind === 'youtube'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addYouTubeToPlaylist"
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<input type="hidden" name="videoId" value={item.song.videoId} />
|
||||
<input type="hidden" name="title" value={item.song.title} />
|
||||
<input type="hidden" name="artist" value={item.song.artist} />
|
||||
<input type="hidden" name="durationSeconds" value={item.song.durationSeconds} />
|
||||
<input type="hidden" name="thumbnailUrl" value={item.song.thumbnailUrl} />
|
||||
<IconButton type="submit" label="Añadir a la playlist" size="sm">
|
||||
<Plus class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</form>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addTrack"
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<input type="hidden" name="trackId" value={item.track.id} />
|
||||
<IconButton type="submit" label="Añadir a la playlist" size="sm">
|
||||
<Plus class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</form>
|
||||
{/if}
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</TrackTable>
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
{playlists}
|
||||
onClose={closeContextMenu}
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import {
|
||||
createApiClient,
|
||||
optionalUser,
|
||||
requireAccessTokenAction,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
|
||||
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const viewer = optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [profileRes, playlistsRes] = await Promise.all([
|
||||
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
|
||||
api.GET('/playlists/users/{userId}', {
|
||||
params: {
|
||||
path: { userId: params.id },
|
||||
query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE, onlyPublic: true }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404);
|
||||
const playlistItems = playlistsRes.data?.items ?? [];
|
||||
|
||||
const tracksByPlaylist = await Promise.all(
|
||||
playlistItems.map((playlist) =>
|
||||
api
|
||||
.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } }
|
||||
})
|
||||
.then((res) => Number(res.data?.totalItemCount ?? 0))
|
||||
)
|
||||
);
|
||||
|
||||
const playlists = playlistItems.map((playlist, i) => ({
|
||||
...playlist,
|
||||
trackCount: tracksByPlaylist[i]
|
||||
}));
|
||||
|
||||
const isOwnProfile = viewer !== null && viewer.sub === params.id;
|
||||
|
||||
const isFollowing =
|
||||
viewer && !isOwnProfile
|
||||
? await api
|
||||
.GET('/users/{id}/is-following', { params: { path: { id: params.id } } })
|
||||
.then((res) => res.data ?? false)
|
||||
.catch(() => false)
|
||||
: false;
|
||||
|
||||
return { profile, playlists, isOwnProfile, isFollowing, isAnonymous: viewer === null };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
follow: async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
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 };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const profile = $derived(data.profile);
|
||||
const playlists = $derived(data.playlists);
|
||||
|
||||
let following = $state(untrack(() => data.isFollowing));
|
||||
let submitting = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'following' in form && typeof form.following === 'boolean')
|
||||
following = form.following;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{profile.name}</title>
|
||||
<meta name="description" content="Perfil de {profile.name} en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
|
||||
<ArtistAvatar name={profile.name} imageUrl={profile.profilePictureUrl} size={144} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Perfil</p>
|
||||
<h1
|
||||
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{profile.name}
|
||||
</h1>
|
||||
<p class="mt-3.25 text-[12.5px] text-fg-2">
|
||||
{profile.followersCount}
|
||||
{profile.followersCount === 1 ? 'seguidor' : 'seguidores'} · {profile.followingCount} siguiendo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !data.isOwnProfile && !data.isAnonymous}
|
||||
<div class="mt-6 flex items-center gap-2.5 sm:mt-7">
|
||||
<form
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-9">
|
||||
<h2 class="font-display text-lg font-semibold tracking-[-0.02em] text-fg">
|
||||
Playlists públicas
|
||||
</h2>
|
||||
|
||||
{#if playlists.length > 0}
|
||||
<MediaGrid min="210px" minMobile="180px" class="mt-6">
|
||||
{#each playlists as playlist, i (playlist.id)}
|
||||
<PlaylistCard
|
||||
id={playlist.id}
|
||||
name={playlist.name}
|
||||
trackCount={playlist.trackCount}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
updatedAt={playlist.updatedAt}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Sin playlists públicas"
|
||||
description="Este usuario todavía no ha compartido ninguna playlist pública."
|
||||
class="mt-6"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Page>
|
||||
@@ -30,9 +30,9 @@
|
||||
let audioInput = $state<HTMLInputElement>();
|
||||
|
||||
const steps = [
|
||||
{ key: 'upload', label: 'Subir', hint: 'Portada y audio al almacenamiento' },
|
||||
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming' },
|
||||
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo' }
|
||||
{ key: 'upload', label: 'Subir', hint: 'Portada y audio.' },
|
||||
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming.' },
|
||||
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' }
|
||||
];
|
||||
|
||||
const busy = $derived(status === 'uploading');
|
||||
@@ -47,14 +47,8 @@
|
||||
return ['idle', 'idle', 'idle'];
|
||||
});
|
||||
|
||||
const dropClass = $derived(
|
||||
dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60'
|
||||
);
|
||||
|
||||
function stepClass(s: StepState) {
|
||||
if (s === 'active') return 'border-accent/40 bg-accent/5';
|
||||
if (s === 'done') return 'border-accent/40';
|
||||
return '';
|
||||
return s === 'idle' ? '' : 'bg-accent-tint';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
@@ -112,37 +106,39 @@
|
||||
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
||||
/>
|
||||
|
||||
<ol class="mt-8 grid gap-3 sm:mt-10 sm:grid-cols-3 sm:gap-4">
|
||||
<ol class="mt-7 grid gap-3 sm:mt-8 sm:grid-cols-3 sm:gap-4">
|
||||
{#each steps as step, i (step.key)}
|
||||
<li>
|
||||
<Surface class={stepClass(stepStates[i])}>
|
||||
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="grid h-8 w-8 place-items-center rounded-full bg-accent-soft text-sm font-bold text-on-accent"
|
||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-bold {stepStates[
|
||||
i
|
||||
] === 'idle'
|
||||
? 'bg-white/[3.5%] text-fg-2'
|
||||
: 'bg-accent-btn text-accent-soft'}"
|
||||
>
|
||||
{#if stepStates[i] === 'done'}
|
||||
<Check class="h-4 w-4" strokeWidth={2.5} />
|
||||
<Check class="h-3.5 w-3.5" strokeWidth={2.5} />
|
||||
{:else}
|
||||
{i + 1}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-fg">{step.label}</span>
|
||||
<span class="text-sm font-medium text-fg">{step.label}</span>
|
||||
</div>
|
||||
<p class="mt-3 text-base text-muted">{step.hint}</p>
|
||||
<p class="mt-2.5 text-xs text-muted">{step.hint}</p>
|
||||
</Surface>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
{#if status === 'done'}
|
||||
<Surface padding="lg" class="mt-10 border-accent/30 bg-accent/10 text-center">
|
||||
<span
|
||||
class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-accent-soft text-on-accent"
|
||||
>
|
||||
<Check class="h-8 w-8" strokeWidth={2.5} />
|
||||
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
|
||||
<span class="mx-auto grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-ink">
|
||||
<Check class="h-7 w-7" strokeWidth={2.5} />
|
||||
</span>
|
||||
<h2 class="mt-6 text-2xl font-semibold tracking-tight text-fg">¡Subida!</h2>
|
||||
<p class="mt-3 text-fg-2">
|
||||
<h2 class="mt-6 font-display text-2xl font-semibold tracking-[-0.02em] text-fg">¡Subida!</h2>
|
||||
<p class="mt-3 text-sm text-fg-2">
|
||||
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
|
||||
unos momentos.
|
||||
</p>
|
||||
@@ -184,7 +180,9 @@
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={onDrop}
|
||||
class="relative flex min-h-56 flex-col items-center justify-center rounded-panel border border-dashed bg-surface p-6 text-center transition sm:min-h-72 sm:p-10 {dropClass}"
|
||||
class="relative flex min-h-48 flex-col items-center justify-center rounded-panel p-6 text-center transition sm:min-h-64 sm:p-10 {dragging
|
||||
? 'bg-surface-2 ring-2 ring-accent/50'
|
||||
: 'bg-surface'}"
|
||||
>
|
||||
<input
|
||||
bind:this={audioInput}
|
||||
@@ -192,33 +190,32 @@
|
||||
name="audio"
|
||||
accept="audio/*"
|
||||
onchange={onAudioInput}
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
class="absolute inset-0 cursor-pointer opacity-0 focus:outline-none"
|
||||
aria-label="Seleccionar archivo de audio"
|
||||
/>
|
||||
<span
|
||||
class="grid h-16 w-16 place-items-center rounded-full bg-accent-soft text-on-accent sm:h-20 sm:w-20"
|
||||
>
|
||||
{#if audioName === ''}
|
||||
<Headphones class="h-8 w-8 sm:h-9 sm:w-9" />
|
||||
{:else}
|
||||
<Music class="h-8 w-8 sm:h-9 sm:w-9" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if audioName === ''}
|
||||
<p class="mt-5 text-fg sm:text-lg">Arrastra tu audio aquí o haz clic para elegir</p>
|
||||
<p class="mt-2 text-sm text-muted">MP3, FLAC, WAV…</p>
|
||||
<Headphones class="h-9 w-9 text-muted sm:h-10 sm:w-10" strokeWidth={1.4} />
|
||||
{:else}
|
||||
<p class="mt-5 max-w-full truncate text-fg sm:text-lg">
|
||||
<Music class="h-9 w-9 text-accent-soft sm:h-10 sm:w-10" strokeWidth={1.4} />
|
||||
{/if}
|
||||
{#if audioName === ''}
|
||||
<p class="mt-4 text-sm font-medium text-fg">
|
||||
Arrastra tu audio aquí o haz clic para elegir
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-muted">MP3, FLAC, WAV…</p>
|
||||
{:else}
|
||||
<p class="mt-4 max-w-full truncate text-sm font-medium text-fg">
|
||||
{audioName}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-muted">{formatSize(audioSize)} · Listo para subir</p>
|
||||
<p class="mt-1.5 text-xs text-muted">{formatSize(audioSize)} · Listo para subir</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8">
|
||||
<Field label="Portada">
|
||||
<label
|
||||
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control border border-line bg-surface transition hover:border-accent/50 sm:h-44 sm:w-44"
|
||||
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control transition sm:h-44 sm:w-44"
|
||||
style={coverPreview === '' ? 'background:var(--mf-cover-grad)' : undefined}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -231,7 +228,7 @@
|
||||
{#if coverPreview !== ''}
|
||||
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<ImageIcon class="h-12 w-12 text-muted" />
|
||||
<ImageIcon class="h-11 w-11 text-white/70" strokeWidth={1.3} />
|
||||
{/if}
|
||||
</label>
|
||||
</Field>
|
||||
@@ -263,12 +260,12 @@
|
||||
</Checkbox>
|
||||
|
||||
{#if status === 'uploading'}
|
||||
<Surface>
|
||||
<div class="flex items-center justify-between text-base">
|
||||
<Surface padding="sm">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
|
||||
<span class="text-muted">un momento</span>
|
||||
<span class="text-xs text-muted">un momento</span>
|
||||
</div>
|
||||
<div class="mt-4 h-2.5 overflow-hidden rounded-full bg-surface-2">
|
||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
|
||||
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
</script>
|
||||
|
||||
<div class="animate-enter mx-auto my-[14vh] max-w-105 text-center">
|
||||
<h1 class="mb-2.5 font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
||||
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
|
||||
</h1>
|
||||
<p class="mb-6.5 text-[13px] leading-[1.65] text-fg-2">
|
||||
{page.status === 404
|
||||
? 'Comprueba el enlace o vuelve al inicio.'
|
||||
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'}
|
||||
</p>
|
||||
<Button href="/" variant="secondary">Volver</Button>
|
||||
</div>
|
||||
@@ -1,8 +1,44 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
import { authConfig } from '$lib/server/config';
|
||||
import { LIKED_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login']);
|
||||
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
||||
|
||||
async function fetchSidebarPlaylists(
|
||||
fetchFn: typeof fetch,
|
||||
accessToken: string | null,
|
||||
userId: string
|
||||
) {
|
||||
try {
|
||||
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
|
||||
const { data } = await api.GET('/playlists/users/{userId}', {
|
||||
params: {
|
||||
path: { userId },
|
||||
query: { pageNumber: 1, pageSize: SIDEBAR_PLAYLISTS_LIMIT }
|
||||
}
|
||||
});
|
||||
return data?.items ?? [];
|
||||
} catch {
|
||||
// The sidebar shell must render even when the API is unreachable.
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
||||
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
|
||||
@@ -10,5 +46,31 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
||||
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
|
||||
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
}
|
||||
return { user: locals.user, allowAnonymousListening };
|
||||
|
||||
const accountUrl = authConfig.issuer
|
||||
? `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`
|
||||
: null;
|
||||
|
||||
if (!locals.user) {
|
||||
return {
|
||||
user: locals.user,
|
||||
allowAnonymousListening,
|
||||
sidebarPlaylists: [],
|
||||
likedTracks: [],
|
||||
accountUrl
|
||||
};
|
||||
}
|
||||
|
||||
const [sidebarPlaylists, likedTracks] = await Promise.all([
|
||||
fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub),
|
||||
fetchLikedTracks(fetch, locals.accessToken)
|
||||
]);
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
allowAnonymousListening,
|
||||
sidebarPlaylists,
|
||||
likedTracks,
|
||||
accountUrl
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import '@fontsource-variable/manrope/index.css';
|
||||
import '@fontsource-variable/bricolage-grotesque/opsz.css';
|
||||
import '@fontsource-variable/sora/index.css';
|
||||
import '@fontsource-variable/plus-jakarta-sans/index.css';
|
||||
import '$lib/theme/theme.css';
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import PlayerBar from '$lib/components/player/PlayerBar.svelte';
|
||||
import PlayerDock from '$lib/components/player/PlayerDock.svelte';
|
||||
import Queue from '$lib/components/player/Queue.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import TopBar from '$lib/components/TopBar.svelte';
|
||||
import MobileHeader from '$lib/components/MobileHeader.svelte';
|
||||
import MobileNav from '$lib/components/MobileNav.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { ACCENT_LIGHTNESS, ACCENT_CHROMA, ACCENT_HUE } from '$lib/theme/color';
|
||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import { ACCENT_HUE } from '$lib/theme/color';
|
||||
import { buildThemeTokens, applyThemeTokens } from '$lib/theme/tokens';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
import { trackNavigation } from '$lib/navigation.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
@@ -18,9 +25,19 @@
|
||||
|
||||
trackNavigation();
|
||||
|
||||
$effect(() => {
|
||||
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
|
||||
});
|
||||
|
||||
const isAuthPage = $derived(page.url.pathname === '/login');
|
||||
const hasTrack = $derived(player.currentId !== null);
|
||||
|
||||
let hadTrack = false;
|
||||
$effect(() => {
|
||||
if (hasTrack && !hadTrack) queuePanel.show();
|
||||
hadTrack = hasTrack;
|
||||
});
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
@@ -33,37 +50,30 @@
|
||||
player.toggle();
|
||||
}
|
||||
|
||||
function parseAccent(value: string): { c: number; h: number } | null {
|
||||
const m = /oklch\(\s*[\d.]+%?\s+([\d.]+)\s+([\d.]+)/.exec(value);
|
||||
return m ? { c: parseFloat(m[1]), h: parseFloat(m[2]) } : null;
|
||||
function parseHue(value: string): number | null {
|
||||
const m = /oklch\(\s*[\d.]+%?\s+[\d.]+\s+([\d.]+)/.exec(value);
|
||||
return m ? parseFloat(m[1]) : null;
|
||||
}
|
||||
|
||||
let displayed = { c: ACCENT_CHROMA, h: ACCENT_HUE };
|
||||
let displayedHue = ACCENT_HUE;
|
||||
let rafId = 0;
|
||||
|
||||
$effect(() => {
|
||||
const target = parseAccent(player.accent);
|
||||
if (!target) {
|
||||
document.documentElement.style.setProperty('--mf-accent', player.accent);
|
||||
return;
|
||||
}
|
||||
const targetHue = parseHue(player.accent) ?? ACCENT_HUE;
|
||||
const mode = themeMode.current;
|
||||
cancelAnimationFrame(rafId);
|
||||
const from = { ...displayed };
|
||||
let dh = target.h - from.h;
|
||||
const fromHue = displayedHue;
|
||||
let dh = targetHue - fromHue;
|
||||
if (dh > 180) dh -= 360;
|
||||
else if (dh < -180) dh += 360;
|
||||
const start = performance.now();
|
||||
const duration = 350;
|
||||
const duration = 600;
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
const e = 1 - Math.pow(1 - t, 3);
|
||||
const c = from.c + (target.c - from.c) * e;
|
||||
const h = (((from.h + dh * e) % 360) + 360) % 360;
|
||||
displayed = { c, h };
|
||||
document.documentElement.style.setProperty(
|
||||
'--mf-accent',
|
||||
`oklch(${ACCENT_LIGHTNESS}% ${c.toFixed(3)} ${h.toFixed(1)})`
|
||||
);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const hue = (((fromHue + dh * eased) % 360) + 360) % 360;
|
||||
displayedHue = hue;
|
||||
applyThemeTokens(buildThemeTokens(hue, mode));
|
||||
if (t < 1) rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
@@ -80,25 +90,53 @@
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen bg-bg text-fg antialiased">
|
||||
<Sidebar user={data.user} />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<MobileHeader user={data.user} />
|
||||
<TopBar user={data.user} />
|
||||
<main
|
||||
class="flex-1 transition-[padding]"
|
||||
style="padding-bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + {hasTrack
|
||||
? 'var(--mf-player-h)'
|
||||
: '0px'})"
|
||||
>
|
||||
{#key page.url.pathname}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
</main>
|
||||
{#if hasTrack}
|
||||
<PlayerBar />
|
||||
{/if}
|
||||
<MobileNav user={data.user} />
|
||||
<div class="relative flex min-h-screen text-fg antialiased">
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 -z-10 bg-bg"
|
||||
style="background-image:var(--mf-ambient)"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 -z-10"
|
||||
style="background:radial-gradient(130% 85% at 50% 118%, color-mix(in oklch, var(--mf-scrim) 80%, transparent), transparent 62%)"
|
||||
></div>
|
||||
|
||||
<Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} />
|
||||
|
||||
<div class="flex min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
|
||||
<TopBar user={data.user} accountUrl={data.accountUrl} />
|
||||
<main
|
||||
class="flex-1 transition-[padding]"
|
||||
style="padding-bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + {hasTrack
|
||||
? 'var(--mf-player-h)'
|
||||
: '0px'})"
|
||||
>
|
||||
{#key page.url.pathname}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Queue />
|
||||
</div>
|
||||
|
||||
<PlayerDock user={data.user} />
|
||||
|
||||
<Modal
|
||||
open={createPlaylistModal.open}
|
||||
onClose={() => createPlaylistModal.close()}
|
||||
title="Crear playlist"
|
||||
>
|
||||
<PlaylistForm
|
||||
action="/playlists?/create"
|
||||
formMessage={page.form?.message}
|
||||
submitLabel="Crear"
|
||||
submittingLabel="Creando…"
|
||||
helperText="Podrás añadir canciones más tarde."
|
||||
onCancel={() => createPlaylistModal.close()}
|
||||
onSuccess={() => createPlaylistModal.close()}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser } from '$lib/server/api';
|
||||
import { fetchYoutubeAlbumFiller, fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import {
|
||||
addAlbumToPlaylistAction,
|
||||
addTrackAction,
|
||||
addYouTubeToPlaylistAction
|
||||
} from '$lib/server/playlistActions';
|
||||
import {
|
||||
HOME_ALBUM_FILLER_LIMIT,
|
||||
HOME_ALBUMS_LIMIT,
|
||||
HOME_LATEST_PAGE_SIZE,
|
||||
HOME_MIXES_BENTO,
|
||||
HOME_MIXES_LIMIT,
|
||||
HOME_POPULAR_FILLER_LIMIT,
|
||||
HOME_SHELF_LIMIT,
|
||||
HOME_TOP_MUSIC_FILLER_LIMIT,
|
||||
YOUTUBE_FILLER_LIMIT
|
||||
HOME_SPOTLIGHT_TRACKS_LIMIT
|
||||
} from '$lib/config';
|
||||
import { pickGreeting } from '$lib/server/greeting';
|
||||
|
||||
@@ -34,18 +27,18 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
query: { pageNumber: 1, pageSize: HOME_SHELF_LIMIT + 1 }
|
||||
}
|
||||
});
|
||||
const albumsPromise = api.GET('/albums/recent', {
|
||||
params: { query: { limit: HOME_ALBUMS_LIMIT } }
|
||||
});
|
||||
const mixesPromise = api.GET('/mixes');
|
||||
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
|
||||
params: { path: { id: user.sub } }
|
||||
});
|
||||
const listeningStatsPromise = api
|
||||
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
|
||||
.then((res) => res.data ?? { tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 })
|
||||
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
|
||||
|
||||
const [latest, playlists, albums, mixes, recentlyPlayed] = await Promise.all([
|
||||
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||
latestPromise,
|
||||
playlistsPromise,
|
||||
albumsPromise,
|
||||
mixesPromise,
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
@@ -54,30 +47,32 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
item.track ? [item.track] : []
|
||||
);
|
||||
|
||||
const spotlightPlaylist = playlistItems[0] ?? null;
|
||||
const spotlightTracksPromise = spotlightPlaylist
|
||||
? api
|
||||
.GET('/playlists/{playlistId}/tracks', {
|
||||
params: {
|
||||
path: { playlistId: spotlightPlaylist.id },
|
||||
query: { pageNumber: 1, pageSize: HOME_SPOTLIGHT_TRACKS_LIMIT }
|
||||
}
|
||||
})
|
||||
.then((res) => ({
|
||||
items: res.data?.items ?? [],
|
||||
totalCount: Number(res.data?.totalItemCount ?? 0)
|
||||
}))
|
||||
.catch(() => ({ items: [], totalCount: 0 }))
|
||||
: Promise.resolve({ items: [], totalCount: 0 });
|
||||
|
||||
return {
|
||||
greeting: pickGreeting(new Date().getHours()),
|
||||
greeting: pickGreeting(new Date()),
|
||||
recentlyPlayed: recentlyPlayed.data ?? [],
|
||||
albums: albums.data ?? [],
|
||||
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_BENTO),
|
||||
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT),
|
||||
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
|
||||
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
|
||||
newReleases: latestItems,
|
||||
topMusicFiller: fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
HOME_TOP_MUSIC_FILLER_LIMIT,
|
||||
undefined,
|
||||
user.sub,
|
||||
3
|
||||
),
|
||||
youtubeFiller: fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
YOUTUBE_FILLER_LIMIT,
|
||||
undefined,
|
||||
user.sub,
|
||||
0
|
||||
),
|
||||
spotlightPlaylist,
|
||||
spotlightTracks: spotlightTracksPromise,
|
||||
listeningStats: listeningStatsPromise,
|
||||
popularFiller: fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
@@ -85,20 +80,11 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
undefined,
|
||||
user.sub,
|
||||
1
|
||||
),
|
||||
albumFiller: fetchYoutubeAlbumFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
HOME_ALBUM_FILLER_LIMIT,
|
||||
undefined,
|
||||
user.sub,
|
||||
2
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
addAlbumToPlaylist: addAlbumToPlaylistAction
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
||||
};
|
||||
|
||||
+366
-506
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
||||
import History from '@lucide/svelte/icons/history';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
||||
import { shuffle as shuffleList } from '$lib/collections';
|
||||
import { fmtTime, fmtDurationLong } from '$lib/format';
|
||||
import {
|
||||
isTargetCurrent,
|
||||
queueItemForTarget,
|
||||
@@ -15,64 +15,33 @@
|
||||
targetExplicit,
|
||||
targetForQueueItem,
|
||||
targetId,
|
||||
targetOwnerUserId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import MixBentoTile from '$lib/components/ui/MixBentoTile.svelte';
|
||||
import Rail from '$lib/components/ui/Rail.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import MixTile from '$lib/components/ui/MixTile.svelte';
|
||||
import EqBars from '$lib/components/ui/EqBars.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import AlbumContextMenu, {
|
||||
albumContextMenuStateFor,
|
||||
type AlbumMenuState
|
||||
} from '$lib/components/AlbumContextMenu.svelte';
|
||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import { appendUnique } from '$lib/collections';
|
||||
import { HOME_LATEST_PAGE_SIZE, HOME_POPULAR_MAX } from '$lib/config';
|
||||
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||
let topMusicFillerItems = $state<YouTubeSong[]>([]);
|
||||
let youtubeFillerItems = $state<YouTubeSong[]>([]);
|
||||
let popularFillerItems = $state<YouTubeSong[]>([]);
|
||||
let albumFillerItems = $state<YouTubeAlbumResult[]>([]);
|
||||
let topMusicRail: ReturnType<typeof Rail> | undefined = $state();
|
||||
let recommendedRail: ReturnType<typeof Rail> | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
topMusicFillerItems = [];
|
||||
let cancelled = false;
|
||||
data.topMusicFiller.then((filler) => {
|
||||
if (cancelled) return;
|
||||
topMusicFillerItems = appendUnique([], filler.items, (song) => song.videoId);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
youtubeFillerItems = [];
|
||||
let cancelled = false;
|
||||
data.youtubeFiller.then((filler) => {
|
||||
if (cancelled) return;
|
||||
youtubeFillerItems = appendUnique([], filler.items, (song) => song.videoId);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
let spotlightItems = $state<TrackTarget[]>([]);
|
||||
let spotlightTotal = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
popularFillerItems = [];
|
||||
@@ -87,96 +56,76 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
albumFillerItems = [];
|
||||
spotlightItems = [];
|
||||
spotlightTotal = 0;
|
||||
let cancelled = false;
|
||||
data.albumFiller.then((filler) => {
|
||||
data.spotlightTracks.then(({ items, totalCount }) => {
|
||||
if (cancelled) return;
|
||||
albumFillerItems = appendUnique([], filler.items, (album) => album.albumId);
|
||||
spotlightItems = items.map((track) => ({ kind: 'local' as const, track }));
|
||||
spotlightTotal = totalCount;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const albums = $derived(data.albums);
|
||||
const mixes = $derived(data.mixes);
|
||||
const playlists = $derived(data.playlists);
|
||||
|
||||
const newTargets = $derived<TrackTarget[]>(
|
||||
data.newReleases.map((track) => ({ kind: 'local' as const, track }))
|
||||
const spotlight = $derived(data.spotlightPlaylist);
|
||||
const spotlightDurationSeconds = $derived(
|
||||
spotlightItems.some((item) => targetDurationSeconds(item) !== undefined)
|
||||
? spotlightItems.reduce((sum, item) => sum + (targetDurationSeconds(item) ?? 0), 0)
|
||||
: undefined
|
||||
);
|
||||
const topMusicTargets = $derived<TrackTarget[]>([
|
||||
...newTargets,
|
||||
...topMusicFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
]);
|
||||
const recTargets = $derived<TrackTarget[]>(
|
||||
youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length);
|
||||
const spotlightVisibilityLabel = $derived(
|
||||
spotlight?.visibility === 'Public' ? 'Pública' : 'Privada'
|
||||
);
|
||||
const spotlightMeta = $derived(
|
||||
spotlightTrackCount === 0
|
||||
? spotlightVisibilityLabel
|
||||
: [
|
||||
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'canción' : 'canciones'}`,
|
||||
spotlightDurationSeconds !== undefined
|
||||
? fmtDurationLong(spotlightDurationSeconds)
|
||||
: '0 min',
|
||||
spotlightVisibilityLabel
|
||||
].join(' · ')
|
||||
);
|
||||
|
||||
type RecommendedAlbum =
|
||||
| { kind: 'recent'; album: (typeof albums)[number] }
|
||||
| { kind: 'discover'; album: YouTubeAlbumResult };
|
||||
|
||||
const recentlyPlayed = $derived.by(() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: QueueItem[] = [];
|
||||
for (const t of player.recentlyPlayed) {
|
||||
const id = String(t.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({
|
||||
id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
source: t.source,
|
||||
coverUrl: t.coverUrl,
|
||||
explicit: t.explicit
|
||||
});
|
||||
}
|
||||
for (const item of toQueueItems(data.recentlyPlayed)) {
|
||||
const id = String(item.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({
|
||||
id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl,
|
||||
explicit: item.explicit
|
||||
});
|
||||
}
|
||||
return merged.slice(0, HOME_LATEST_PAGE_SIZE);
|
||||
});
|
||||
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
|
||||
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
|
||||
|
||||
const popularTargets = $derived<TrackTarget[]>(
|
||||
[
|
||||
...recentlyPlayed.map(targetForQueueItem),
|
||||
...recentPool.map(targetForQueueItem),
|
||||
...popularFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
].slice(0, HOME_POPULAR_MAX)
|
||||
);
|
||||
|
||||
const recommendedAlbums = $derived<RecommendedAlbum[]>([
|
||||
...albums.map((album) => ({ kind: 'recent' as const, album })),
|
||||
...albumFillerItems.map((album) => ({ kind: 'discover' as const, album }))
|
||||
]);
|
||||
|
||||
const heroTrack = $derived(recentlyPlayed[0] ?? null);
|
||||
const heroPlaying = $derived(
|
||||
heroTrack !== null && player.current.id === heroTrack.id && player.playing
|
||||
const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget));
|
||||
const spotlightIsCurrent = $derived(
|
||||
spotlightQueue.length > 0 && spotlightQueue.some((item) => item.id === player.current.id)
|
||||
);
|
||||
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
|
||||
|
||||
function playRecent(index: number) {
|
||||
player.playOrToggle(recentlyPlayed, index);
|
||||
}
|
||||
function playTopMusic(index: number) {
|
||||
player.playOrToggle(topMusicTargets.map(queueItemForTarget), index);
|
||||
function playContinue(index: number) {
|
||||
player.playOrToggle(continueItems, index);
|
||||
}
|
||||
function playPopular(index: number) {
|
||||
player.playOrToggle(popularTargets.map(queueItemForTarget), index);
|
||||
}
|
||||
function playRecommended(index: number) {
|
||||
player.playOrToggle(recTargets.map(queueItemForTarget), index);
|
||||
function playSpotlightTrack(index: number) {
|
||||
player.playOrToggle(spotlightQueue, index);
|
||||
}
|
||||
function playSpotlight() {
|
||||
if (spotlightQueue.length === 0) return;
|
||||
if (spotlightIsCurrent) player.toggle();
|
||||
else player.playQueue(spotlightQueue, 0);
|
||||
}
|
||||
function shuffleSpotlight() {
|
||||
if (spotlightQueue.length === 0) return;
|
||||
player.playQueue(shuffleList(spotlightQueue), 0);
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||
@@ -187,39 +136,6 @@
|
||||
player.addToQueue(queueItemForTarget(contextMenu));
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
|
||||
albumMenu = albumContextMenuStateFor(event, kind, albumId);
|
||||
}
|
||||
|
||||
async function albumPlayNext() {
|
||||
if (!albumMenu) return;
|
||||
const { kind, albumId } = albumMenu;
|
||||
albumMenu = null;
|
||||
player.playNext(await fetchAlbumQueueItems(kind, albumId));
|
||||
}
|
||||
|
||||
async function albumAddToQueue() {
|
||||
if (!albumMenu) return;
|
||||
const { kind, albumId } = albumMenu;
|
||||
albumMenu = null;
|
||||
player.appendToQueue(await fetchAlbumQueueItems(kind, albumId));
|
||||
}
|
||||
|
||||
function albumCaption(album: (typeof albums)[number]) {
|
||||
return [
|
||||
album.releaseYear ? String(album.releaseYear) : undefined,
|
||||
`${Number(album.trackCount)} ${Number(album.trackCount) === 1 ? 'canción' : 'canciones'}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function youtubeAlbumCaption(album: YouTubeAlbumResult) {
|
||||
return [album.artist, album.releaseYear ? String(album.releaseYear) : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -227,399 +143,353 @@
|
||||
<meta name="description" content="Tu música, sin límites." />
|
||||
</svelte:head>
|
||||
|
||||
{#snippet railArrows(rail: ReturnType<typeof Rail> | undefined)}
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => rail?.scrollByPage(-1)}
|
||||
aria-label="Anterior"
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-line text-fg-2 transition hover:border-accent-soft/50 hover:text-accent-soft"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => rail?.scrollByPage(1)}
|
||||
aria-label="Siguiente"
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-line text-fg-2 transition hover:border-accent-soft/50 hover:text-accent-soft"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section class="relative overflow-hidden page-x pt-8 pb-8 sm:pt-10 sm:pb-10">
|
||||
<div class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<section class="page-x pt-3 sm:pt-4">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-[24px] bg-[image:var(--mf-hero-bg)] px-6 py-8 transition-[background] duration-[600ms] sm:px-9 sm:py-8.5"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 top-[-19rem] h-[34rem] sm:h-[42rem]"
|
||||
style="background:radial-gradient(58% 100% at 50% 0%, color-mix(in oklch, var(--color-accent), transparent 5%), color-mix(in oklch, var(--color-accent), transparent 48%) 42%, transparent 78%); animation:breathe 14s ease-in-out infinite"
|
||||
></div>
|
||||
<div class="absolute inset-x-0 bottom-0 h-16 bg-linear-to-b from-transparent to-bg"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-10 lg:flex-row lg:items-end lg:justify-between lg:gap-8">
|
||||
<div class="animate-enter flex max-w-2xl flex-col gap-4">
|
||||
<p class="text-xs font-semibold tracking-[0.28em] text-fg-2 uppercase sm:text-sm">
|
||||
{data.greeting}
|
||||
</p>
|
||||
<h1
|
||||
class="font-display text-[clamp(3rem,7.5vw,5.5rem)] leading-[0.98] font-medium tracking-tight text-fg"
|
||||
>
|
||||
Tu música,<br /><span class="text-accent-soft">sin límites.</span>
|
||||
</h1>
|
||||
<p class="max-w-md text-base text-fg-2 sm:text-lg">
|
||||
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if heroTrack}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playRecent(0)}
|
||||
aria-label="{heroPlaying ? 'Pausar' : 'Reproducir'} {heroTrack.title}"
|
||||
class="group/hero animate-enter relative flex w-full max-w-sm shrink-0 items-center gap-4 overflow-hidden rounded-panel border border-line-strong bg-surface/70 p-4 text-left backdrop-blur-xl transition duration-300 hover:border-accent-soft/50 hover:bg-surface-hover hover:shadow-glow sm:p-5"
|
||||
style="animation-delay:140ms"
|
||||
>
|
||||
<Cover
|
||||
trackId={heroTrack.id}
|
||||
src={heroTrack.coverUrl}
|
||||
size="medium"
|
||||
alt={heroTrack.title}
|
||||
class="h-16 w-16 shrink-0 rounded-art shadow-art sm:h-20 sm:w-20"
|
||||
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
|
||||
>
|
||||
<div class="animate-enter max-w-135 min-w-65">
|
||||
<h1
|
||||
class="font-display text-[28px] leading-[1.1] font-semibold tracking-[-0.035em] text-pretty text-fg sm:text-[36px]"
|
||||
>
|
||||
{#if player.current.id === heroTrack.id}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[0.65rem] font-semibold tracking-[0.2em] text-fg-2 uppercase">
|
||||
Seguir escuchando
|
||||
</p>
|
||||
<p class="mt-1 truncate font-display text-lg text-fg">{heroTrack.title}</p>
|
||||
{#if heroTrack.artist}
|
||||
<p class="truncate text-sm text-fg-2">{heroTrack.artist}</p>
|
||||
{data.greeting}
|
||||
</h1>
|
||||
<p class="mt-3.5 max-w-108 text-[13.5px] leading-[1.65] text-fg-2">
|
||||
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
|
||||
</p>
|
||||
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
|
||||
{#if spotlight}
|
||||
<Button
|
||||
variant="accent"
|
||||
class="!bg-cta-strong !font-bold !text-ink hover:!brightness-95"
|
||||
onclick={playSpotlight}
|
||||
>
|
||||
{heroPlaying ? 'Pausar' : 'Reanudar'}
|
||||
{spotlight.name}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button href="/explore" variant="secondary" class="!font-normal">Explorar música</Button>
|
||||
</div>
|
||||
<span
|
||||
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-accent-soft text-on-accent shadow-art transition duration-300 group-hover/hero:scale-105"
|
||||
>
|
||||
{#if heroPlaying}
|
||||
<Pause class="h-4.5 w-4.5" fill="currentColor" />
|
||||
{:else}
|
||||
<Play class="h-4.5 w-4.5 translate-x-0.5" fill="currentColor" />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#await data.listeningStats then stats}
|
||||
<div class="flex gap-6.5">
|
||||
<div>
|
||||
<div class="font-display text-[25px] font-semibold tracking-[-0.03em] text-fg">
|
||||
{stats.tracksThisWeek}
|
||||
</div>
|
||||
<div class="mt-1 text-[11.5px] text-fg-3">Canciones nuevas escuchadas esta semana</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-display text-[25px] font-semibold tracking-[-0.03em] text-fg">
|
||||
{fmtDurationLong(Number(stats.secondsThisWeek))}
|
||||
</div>
|
||||
<div class="mt-1 text-[11.5px] text-fg-3">Tiempo de escucha esta semana</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-display text-[25px] font-semibold tracking-[-0.03em] text-fg">
|
||||
{stats.streakDays}
|
||||
</div>
|
||||
<div class="mt-1 text-[11.5px] text-fg-3">Días consecutivos escuchando</div>
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-12 page-x pb-8 sm:pb-10">
|
||||
{#if topMusicTargets.length > 0}
|
||||
<section>
|
||||
<SectionHeading title="Top música">
|
||||
{#snippet actions()}
|
||||
{@render railArrows(topMusicRail)}
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
<Rail bind:this={topMusicRail}>
|
||||
{#each topMusicTargets as target, i (targetId(target))}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playTopMusic(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||
aria-label="Reproducir {targetTitle(target)}"
|
||||
class="group/card animate-enter block w-36 shrink-0 rounded-art text-left focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
<div class="flex flex-col gap-11.5 page-x pt-10 pb-8 sm:pb-10">
|
||||
<!-- CONTINUAR ESCUCHANDO -->
|
||||
<section>
|
||||
<SectionHeading title="Continuar escuchando" />
|
||||
{#if continueItems.length > 0}
|
||||
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{#each continueItems as item, i (item.id)}
|
||||
{@const isCurrent = player.current.id === item.id}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => playContinue(i)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
playContinue(i);
|
||||
}}
|
||||
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
|
||||
class="group/cont animate-enter flex items-center gap-3.25 rounded-[13px] p-2.25 pr-3.5 text-left transition-colors {isCurrent
|
||||
? 'bg-accent-tint'
|
||||
: 'hover:bg-white/[3%]'}"
|
||||
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
||||
>
|
||||
<div class="relative h-36 w-36">
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="large"
|
||||
alt={targetTitle(target)}
|
||||
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
<Cover
|
||||
trackId={item.id}
|
||||
src={item.coverUrl}
|
||||
size="small"
|
||||
alt={item.title}
|
||||
class="h-13 w-13 shrink-0 rounded-[10px]"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="truncate text-sm leading-[1.3] font-medium {isCurrent
|
||||
? 'text-accent-soft'
|
||||
: 'text-fg'}"
|
||||
>
|
||||
{#if isTargetCurrent(target)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
{item.title}
|
||||
</div>
|
||||
<ArtistLink
|
||||
name={item.artist || '—'}
|
||||
ownerUserId={item.ownerUserId}
|
||||
class="mt-0.75 text-[12.5px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-fg transition-colors group-hover/card:text-accent-soft">
|
||||
{targetTitle(target)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={History}
|
||||
title="Aún no has escuchado nada"
|
||||
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- PLAYLIST DESTACADA -->
|
||||
{#if spotlight}
|
||||
<section>
|
||||
<div
|
||||
class="grid grid-cols-1 overflow-hidden rounded-panel-lg bg-[image:var(--mf-spotlight-bg)] transition-[background] duration-[600ms] lg:grid-cols-2"
|
||||
>
|
||||
<div class="flex flex-col justify-center gap-5 p-8 sm:p-9">
|
||||
<div class="flex items-center gap-5">
|
||||
<PlaylistArt
|
||||
playlistId={spotlight.id}
|
||||
trackIds={spotlight.coverTrackIds}
|
||||
version={spotlight.updatedAt}
|
||||
size="large"
|
||||
class="h-28 w-28 shrink-0 rounded-[16px] shadow-[0_16px_40px_rgba(0,0,0,.5)]"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-semibold tracking-[0.16em] text-fg-2 uppercase">
|
||||
Playlist destacada
|
||||
</p>
|
||||
<h3
|
||||
class="mt-2.5 truncate font-display text-[28px] font-semibold tracking-[-0.03em] text-fg"
|
||||
>
|
||||
{spotlight.name}
|
||||
</h3>
|
||||
<p class="mt-2 text-[13px] text-fg-2">{spotlightMeta}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="max-w-110 text-sm leading-[1.7] text-fg-2">
|
||||
{spotlight.description || 'Tu colección, siempre a mano.'}
|
||||
</p>
|
||||
<div class="mt-1 flex gap-2.5">
|
||||
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
||||
<Button variant="secondary" class="!font-normal" onclick={shuffleSpotlight}>
|
||||
Aleatorio
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-0.5 p-5.5 sm:p-6"
|
||||
style="background-image:linear-gradient(to right, color-mix(in oklch, var(--mf-scrim) 26%, transparent), transparent)"
|
||||
>
|
||||
{#each spotlightItems as target, i (targetId(target))}
|
||||
{@const durationSeconds = targetDurationSeconds(target)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => playSpotlightTrack(i)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
playSpotlightTrack(i);
|
||||
}}
|
||||
class="flex items-center gap-3 rounded-[10px] p-2 text-left transition-colors hover:bg-white/[3%] {isTargetCurrent(
|
||||
target
|
||||
)
|
||||
? 'bg-accent-tint'
|
||||
: ''}"
|
||||
>
|
||||
<span class="w-5 shrink-0 text-center text-[11.5px] text-muted tabular-nums"
|
||||
>{i + 1}</span
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="truncate text-[12.5px] font-medium {isTargetCurrent(target)
|
||||
? 'text-accent-soft'
|
||||
: 'text-fg'}"
|
||||
>
|
||||
{targetTitle(target)}
|
||||
</div>
|
||||
<ArtistLink
|
||||
name={targetArtist(target)}
|
||||
ownerUserId={targetOwnerUserId(target)}
|
||||
class="mt-0.5 text-[11px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
<span class="shrink-0 text-[11.5px] text-muted tabular-nums">
|
||||
{durationSeconds !== undefined ? fmtTime(durationSeconds) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</Rail>
|
||||
{:else}
|
||||
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-8">
|
||||
<section class="min-w-0">
|
||||
<SectionHeading title="Popular" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<!-- MIXES -->
|
||||
<section>
|
||||
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
||||
{#if mixes.length > 0}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(164px,1fr))] gap-5">
|
||||
{#each mixes as mix, i (mix.id)}
|
||||
<MixTile {mix} index={i} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Shuffle}
|
||||
title="Aún no tienes mezclas"
|
||||
description="Se generan automáticamente a partir de lo que escuchas."
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- POPULARES ESTA SEMANA -->
|
||||
<section>
|
||||
<SectionHeading
|
||||
title="Populares esta semana"
|
||||
subtitle="Lo más escuchado entre quienes comparten tu gusto"
|
||||
/>
|
||||
{#if popularTargets.length > 0}
|
||||
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
||||
{#each popularTargets as target, i (targetId(target))}
|
||||
{@const isCurrent = isTargetCurrent(target)}
|
||||
<div
|
||||
class="group/row flex min-w-0 items-center gap-3 rounded-control p-2 transition duration-200 {isCurrent
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-hover'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => playPopular(i)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
playPopular(i);
|
||||
}}
|
||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||
class="flex items-center gap-3.5 rounded-[11px] p-2 text-left transition-colors {isCurrent
|
||||
? 'bg-accent-tint'
|
||||
: 'hover:bg-white/[3%]'}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playPopular(i)}
|
||||
aria-label="{isCurrent && player.playing ? 'Pausar' : 'Reproducir'} {targetTitle(
|
||||
target
|
||||
)}"
|
||||
class="relative shrink-0"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-11 w-11 rounded-control shadow-art transition duration-200"
|
||||
>
|
||||
{#if isCurrent}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
{#if !isCurrent}
|
||||
<span
|
||||
class="absolute inset-0 grid place-items-center rounded-control bg-scrim/0 opacity-0 transition duration-200 group-hover/row:bg-scrim/50 group-hover/row:opacity-100"
|
||||
>
|
||||
<Play class="h-3.5 w-3.5 translate-x-0.5 text-on-art" fill="currentColor" />
|
||||
</span>
|
||||
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
||||
{#if isCurrent}
|
||||
<EqBars size={13} paused={!player.playing} />
|
||||
{:else}
|
||||
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button" onclick={() => playPopular(i)} class="min-w-0 flex-1 text-left">
|
||||
</span>
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-9.5 w-9.5 shrink-0 rounded-[8px]"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span
|
||||
class="truncate text-sm text-fg transition-colors group-hover/row:text-accent-soft"
|
||||
class="truncate text-[12.5px] font-medium {isCurrent
|
||||
? 'text-accent-soft'
|
||||
: 'text-fg'}"
|
||||
>
|
||||
{targetTitle(target)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => openContextMenu(e, target)}
|
||||
aria-label="Más opciones"
|
||||
class="shrink-0 rounded-full p-1.5 text-fg-2 opacity-0 transition group-hover/row:opacity-100 hover:text-fg"
|
||||
>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</button>
|
||||
<ArtistLink
|
||||
name={targetArtist(target)}
|
||||
ownerUserId={targetOwnerUserId(target)}
|
||||
class="mt-0.5 text-[11.5px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="min-w-0">
|
||||
<SectionHeading title="Álbumes recomendados">
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={History}
|
||||
title="Aún no has escuchado nada"
|
||||
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
|
||||
>
|
||||
{#snippet actions()}
|
||||
{@render railArrows(recommendedRail)}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
<Rail bind:this={recommendedRail}>
|
||||
{#each recommendedAlbums as entry, i (entry.kind === 'recent' ? entry.album.id : entry.album.albumId)}
|
||||
{#if entry.kind === 'recent'}
|
||||
{@const album = entry.album}
|
||||
<a
|
||||
href={album.source === 'YouTube'
|
||||
? `/albums/external/youtube/${album.externalId}`
|
||||
: `/albums/${album.id}`}
|
||||
class="group/card animate-enter block w-36 shrink-0 text-center"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) =>
|
||||
openAlbumMenu(e, album.source === 'YouTube' ? 'youtube' : 'local', album.id)}
|
||||
>
|
||||
{#if album.source === 'YouTube' && album.thumbnailUrl}
|
||||
<Cover
|
||||
trackId={album.id}
|
||||
src={album.thumbnailUrl}
|
||||
size="large"
|
||||
alt={album.title}
|
||||
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
{:else}
|
||||
<PlaylistArt
|
||||
trackIds={album.coverTrackIds}
|
||||
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-2">{albumCaption(album)}</div>
|
||||
</a>
|
||||
{:else}
|
||||
{@const album = entry.album}
|
||||
<a
|
||||
href="/albums/external/youtube/{album.albumId}"
|
||||
class="group/card animate-enter block w-36 shrink-0 text-center"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', album.albumId)}
|
||||
>
|
||||
<Cover
|
||||
trackId={album.albumId}
|
||||
src={album.thumbnailUrl}
|
||||
size="large"
|
||||
alt={album.title}
|
||||
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
<div
|
||||
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-2">{youtubeAlbumCaption(album)}</div>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</Rail>
|
||||
</section>
|
||||
</div>
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if mixes.length > 0}
|
||||
<section>
|
||||
<SectionHeading title="Mezclas" />
|
||||
<div class="grid grid-cols-2 gap-3 sm:auto-rows-25 sm:grid-cols-4 sm:gap-4">
|
||||
{#each mixes as mix, i (mix.id)}
|
||||
<MixBentoTile
|
||||
{mix}
|
||||
index={i}
|
||||
featured={i === 0}
|
||||
banner={i === 4}
|
||||
class={i === 0
|
||||
? 'col-span-2 min-h-42 sm:row-span-2 sm:min-h-0'
|
||||
: i === 1
|
||||
? 'col-span-2 min-h-30 sm:min-h-0'
|
||||
: i === 4
|
||||
? 'col-span-2 min-h-24 sm:col-span-4 sm:min-h-0'
|
||||
: 'min-h-30 sm:min-h-0'}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if playlists.length > 0}
|
||||
<section>
|
||||
<SectionHeading title="Listas de reproducción" />
|
||||
<Rail>
|
||||
<!-- TUS PLAYLISTS -->
|
||||
<section>
|
||||
<SectionHeading title="Tus playlists" subtitle="Creadas y guardadas por ti">
|
||||
{#snippet actions()}
|
||||
{#if data.playlistsHasMore}
|
||||
<a
|
||||
href="/playlists"
|
||||
class="shrink-0 text-[11.5px] font-semibold tracking-[0.07em] text-fg-3 uppercase transition-colors hover:text-fg"
|
||||
>
|
||||
Ver todas
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
{#if playlists.length > 0}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
|
||||
{#each playlists as playlist, i (playlist.id)}
|
||||
<a
|
||||
href="/playlists/{playlist.id}"
|
||||
class="group/card animate-enter block w-40 shrink-0 pr-2 pb-2"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
class="group/card animate-enter flex items-center gap-3.5 rounded-[15px] p-3 transition-colors hover:bg-white/[3%]"
|
||||
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
||||
>
|
||||
<div class="relative h-40 w-40">
|
||||
<div
|
||||
class="absolute inset-0 translate-x-2 translate-y-2 rounded-art bg-surface-2 ring-1 ring-line"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 translate-x-1 translate-y-1 rounded-art bg-surface-hover ring-1 ring-line"
|
||||
></div>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
version={playlist.updatedAt}
|
||||
class="absolute inset-0 h-full w-full rounded-art shadow-art-lg ring-1 ring-line-strong"
|
||||
/>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
version={playlist.updatedAt}
|
||||
size="medium"
|
||||
class="h-15.5 w-15.5 shrink-0 rounded-[11px] shadow-[0_10px_24px_rgba(0,0,0,.42)]"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[13.5px] font-semibold tracking-[-0.01em] text-fg">
|
||||
{playlist.name}
|
||||
</div>
|
||||
{#if playlist.description}
|
||||
<div class="mt-1 truncate text-[11.5px] text-fg-3">{playlist.description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{playlist.name}
|
||||
</div>
|
||||
{#if playlist.description}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-2">{playlist.description}</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
<a
|
||||
href="/playlists"
|
||||
class="group/more grid h-40 w-40 shrink-0 place-items-center rounded-art border border-dashed border-line text-fg-2 transition duration-300 hover:border-accent-soft/50 hover:text-accent-soft"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span
|
||||
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 transition-transform duration-300 group-hover/more:translate-x-0.5"
|
||||
>
|
||||
<ArrowRight class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="text-sm">Ver todas</span>
|
||||
</div>
|
||||
</a>
|
||||
</Rail>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if recTargets.length > 0}
|
||||
<section>
|
||||
<SectionHeading title="Quizá te guste" />
|
||||
<ul class="grid grid-cols-1 gap-1 lg:grid-cols-2 lg:gap-x-8">
|
||||
{#each recTargets as target, i (targetId(target))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playRecommended(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||
aria-label="Reproducir {targetTitle(target)}"
|
||||
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-hover focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
||||
>
|
||||
{#if isTargetCurrent(target)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span
|
||||
class="truncate text-sm text-fg transition-colors group-hover/row:text-accent-soft"
|
||||
>
|
||||
{targetTitle(target)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if targetDurationSeconds(target)}
|
||||
<span class="shrink-0 text-sm text-fg-2 tabular-nums">
|
||||
{fmtTime(targetDurationSeconds(target) ?? 0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Todavía no tienes listas"
|
||||
description="Crea tu primera lista y añádele canciones de tu biblioteca."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/playlists">Crear lista</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
@@ -630,13 +500,3 @@
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if albumMenu}
|
||||
<AlbumContextMenu
|
||||
menu={albumMenu}
|
||||
{playlists}
|
||||
onClose={() => (albumMenu = null)}
|
||||
onPlayNext={albumPlayNext}
|
||||
onAddToQueue={albumAddToQueue}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
|
||||
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const result = await api.POST('/likes/toggle', { body });
|
||||
|
||||
if (result.error || result.data === undefined)
|
||||
return json({ message: 'No se pudo actualizar Me gusta.' }, { status: 502 });
|
||||
|
||||
return json({ liked: result.data });
|
||||
};
|
||||
@@ -1,27 +1,36 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Manrope Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Bricolage Grotesque Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: 'Plus Jakarta Sans Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Sora Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--color-bg: var(--mf-bg);
|
||||
--color-elevated: var(--mf-elevated);
|
||||
--color-surface: var(--mf-surface);
|
||||
--color-surface-2: var(--mf-surface-2);
|
||||
--color-surface-hover: var(--mf-surface-hover);
|
||||
--color-hover: rgba(255, 255, 255, 0.05);
|
||||
--color-hover: rgba(255, 255, 255, 0.045);
|
||||
--color-line: var(--mf-border);
|
||||
--color-line-strong: var(--mf-border-strong);
|
||||
--color-hairline: var(--mf-hairline);
|
||||
--color-track: var(--mf-track);
|
||||
--color-fg: var(--mf-text);
|
||||
--color-fg-2: var(--mf-text-2);
|
||||
--color-fg-3: var(--mf-text-3);
|
||||
--color-muted: var(--mf-text-4);
|
||||
--color-accent: var(--mf-accent);
|
||||
--color-accent-soft: var(--mf-accent-soft);
|
||||
--color-accent-soft: var(--mf-accent-title);
|
||||
--color-accent-muted: var(--mf-accent-muted);
|
||||
--color-accent-tint: var(--mf-accent-soft-bg);
|
||||
--color-accent-line: var(--mf-accent-line);
|
||||
--color-accent-hair: var(--mf-accent-hair);
|
||||
--color-accent-btn: var(--mf-accent-btn-bg);
|
||||
--color-accent-btn-hover: var(--mf-accent-btn-bg-hover);
|
||||
--color-accent-glow: var(--mf-accent-glow);
|
||||
--color-accent-dim: var(--mf-accent-dim);
|
||||
--color-on-accent: var(--mf-on-art);
|
||||
--color-ink: var(--mf-ink);
|
||||
--color-cta-strong: var(--mf-cta-strong-bg);
|
||||
--color-scrim: var(--mf-scrim);
|
||||
--color-on-art: var(--mf-on-art);
|
||||
--color-danger: oklch(63.7% 0.15 25.331);
|
||||
@@ -29,13 +38,15 @@
|
||||
|
||||
--radius-control: 0.625rem;
|
||||
--radius-panel: 1.125rem;
|
||||
--radius-art: 0.85rem;
|
||||
--radius-panel-lg: 1.375rem;
|
||||
--radius-art: 0.8125rem;
|
||||
--radius-art-lg: 1.125rem;
|
||||
--radius-art-round: 1.75rem;
|
||||
|
||||
--shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
|
||||
--shadow-art: 0 14px 32px rgba(0, 0, 0, 0.45);
|
||||
--shadow-art-lg: 0 24px 50px -16px rgba(0, 0, 0, 0.75);
|
||||
--shadow-menu: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
|
||||
--shadow-player: 0 20px 56px rgba(0, 0, 0, 0.6);
|
||||
--shadow-glow: 0 0 0 1px var(--mf-border-strong), 0 20px 45px -20px var(--mf-accent-glow);
|
||||
}
|
||||
|
||||
@@ -165,6 +176,14 @@ button:disabled {
|
||||
animation: fade-in 0.4s ease both;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background-color: var(--mf-panel-bg);
|
||||
background-image: var(--mf-modal-glow);
|
||||
backdrop-filter: blur(22px);
|
||||
-webkit-backdrop-filter: blur(22px);
|
||||
transition: background 0.6s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
@@ -12,14 +12,15 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative grid min-h-screen place-items-center overflow-hidden bg-bg px-6 text-fg">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style="background:radial-gradient(ellipse 46rem 28rem at 50% 36%, color-mix(in oklch, var(--color-accent), transparent 82%), transparent 70%)"
|
||||
></div>
|
||||
<div class="pointer-events-none absolute inset-0" style="background:var(--mf-ambient)"></div>
|
||||
|
||||
<div class="relative flex w-full max-w-4xl flex-col items-center gap-12 text-center">
|
||||
<div class="font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight">
|
||||
Musify
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="h-8 w-8 rounded-xl bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
|
||||
></span>
|
||||
<span class="font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight">
|
||||
Musify
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="animate-enter flex flex-col items-center gap-5">
|
||||
@@ -39,7 +40,7 @@
|
||||
<Button
|
||||
href={`/auth/login?mode=register&${returnParam}`}
|
||||
reload
|
||||
variant="subtle"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user