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:
@@ -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>
|
||||
Reference in New Issue
Block a user