Design Prot Merge #1
@@ -69,7 +69,7 @@
|
||||
? 'text-fg'
|
||||
: 'text-fg-3 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="h-6 w-6 shrink-0" style={active ? 'color:var(--mf-accent-60)' : ''} />
|
||||
<link.icon class="h-6 w-6 shrink-0 {active ? 'text-accent-soft' : ''}" />
|
||||
{link.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
@@ -87,10 +87,7 @@
|
||||
: ''}"
|
||||
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 left-0 w-[3px] rounded-r-full"
|
||||
style="background:var(--mf-accent-60)"
|
||||
></span>
|
||||
<span class="absolute inset-y-0 left-0 w-[3px] rounded-r-full bg-accent-soft"></span>
|
||||
</div>
|
||||
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fmtTime } from '$lib/theme/color';
|
||||
import { fmtTime } from '$lib/format';
|
||||
|
||||
function seek(event: MouseEvent) {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
@@ -23,11 +23,11 @@
|
||||
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-white opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-opacity duration-75 group-hover:opacity-100"
|
||||
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>
|
||||
</button>
|
||||
<span class="text-xs text-(--mf-text-4) tabular-nums">
|
||||
<span class="text-xs text-muted tabular-nums">
|
||||
{fmtTime(player.current.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
oninput={handleVolumeChange}
|
||||
aria-label="Volumen"
|
||||
class="mf-volume"
|
||||
style="background:linear-gradient(to right, var(--mf-accent-60) {player.volume}%, var(--mf-track) {player.volume}%)"
|
||||
style="background:linear-gradient(to right, var(--color-accent-soft) {player.volume}%, var(--color-track) {player.volume}%)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--mf-text);
|
||||
background: var(--color-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-volume::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--mf-text);
|
||||
background: var(--color-fg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
primary: 'bg-accent-soft font-semibold text-on-accent active:scale-[0.97]',
|
||||
secondary: 'border border-line text-fg hover:bg-hover',
|
||||
subtle: 'bg-surface-2 font-semibold text-fg hover:bg-surface-hover',
|
||||
danger: 'bg-danger font-semibold text-white hover:brightness-110'
|
||||
danger: 'bg-danger font-semibold text-on-accent hover:brightness-110'
|
||||
};
|
||||
|
||||
const classes = $derived(
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
|
||||
role="presentation"
|
||||
onclick={onBackdropClick}
|
||||
>
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-black/45 pb-2">
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-scrim/45 pb-2">
|
||||
{#each bars as bar, i (i)}
|
||||
<span
|
||||
class="w-[3px] origin-bottom rounded-full bg-white"
|
||||
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
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function fmtTime(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds || 0));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const rest = total % 60;
|
||||
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||
|
||||
export function fmtDate(value: string | number | Date): string {
|
||||
return dateFormatter.format(new Date(value));
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export interface ApiTrackLike {
|
||||
isExplicit?: boolean;
|
||||
}
|
||||
|
||||
export function isYouTubeTrack(track: ApiTrackLike): boolean {
|
||||
function isYouTubeTrack(track: ApiTrackLike): boolean {
|
||||
return track.source === 'YouTube';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { createApiClient } from '$lib/server/api';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
export const YOUTUBE_FILLER_QUERIES = [
|
||||
const YOUTUBE_FILLER_QUERIES = [
|
||||
'pop',
|
||||
'rock',
|
||||
'reggaeton',
|
||||
@@ -24,7 +24,7 @@ export interface YoutubeFiller {
|
||||
continuationToken: string;
|
||||
}
|
||||
|
||||
export function pickRandomFillerQuery(): string {
|
||||
function pickRandomFillerQuery(): string {
|
||||
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,23 +15,6 @@ export function accentForHue(hue: number): string {
|
||||
return `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${hue})`;
|
||||
}
|
||||
|
||||
export function accentSoft(hue: number, pct: number): string {
|
||||
return `color-mix(in srgb, ${accentForHue(hue)} ${pct}%, transparent)`;
|
||||
}
|
||||
|
||||
export function gradientForHue(hue: number): string {
|
||||
return `linear-gradient(135deg, oklch(70% 0.15 ${hue}), oklch(36% 0.13 ${hue + 28}))`;
|
||||
}
|
||||
|
||||
export function fmtTime(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds || 0));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const rest = total % 60;
|
||||
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||
|
||||
export function fmtDate(value: string | number | Date): string {
|
||||
return dateFormatter.format(new Date(value));
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
--mf-track: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--mf-accent: oklch(60% 0.17 145);
|
||||
--mf-accent-60: color-mix(in oklch, var(--mf-accent) 80%, transparent);
|
||||
--mf-radius: 12px;
|
||||
--mf-accent-soft: color-mix(in oklch, var(--mf-accent) 80%, transparent);
|
||||
|
||||
--mf-scrim: #000000;
|
||||
--mf-on-art: #ffffff;
|
||||
|
||||
--mf-sidebar-w: 264px;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function targetCoverSrc(target: TrackTarget): string | undefined {
|
||||
return target.kind === 'youtube' ? target.song.thumbnailUrl : undefined;
|
||||
}
|
||||
|
||||
export function queueIdForTarget(target: TrackTarget): string | number {
|
||||
function queueIdForTarget(target: TrackTarget): string | number {
|
||||
return target.kind === 'youtube' ? target.song.videoId : queueIdForTrack(target.track);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import { EXPLORE_PAGE_SIZE as PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
||||
@@ -8,22 +8,20 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
||||
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const tracksPromise = api.GET('/tracks', {
|
||||
params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } }
|
||||
});
|
||||
|
||||
const searchPromise =
|
||||
query && locals.accessToken
|
||||
const searchPromise = query
|
||||
? api.GET('/youtube/search', { params: { query: { query } } })
|
||||
: Promise.resolve(null);
|
||||
|
||||
const playlistsPromise = locals.user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
});
|
||||
|
||||
const [tracksRes, searchRes, playlistsRes] = await Promise.all([
|
||||
tracksPromise,
|
||||
@@ -41,8 +39,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
youtubeFiller: query
|
||||
? Promise.resolve(null)
|
||||
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
||||
playlists: playlistsRes?.data?.items ?? [],
|
||||
needsAuth: !locals.user
|
||||
playlists: playlistsRes?.data?.items ?? []
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -84,9 +84,7 @@
|
||||
|
||||
const hasMore = $derived(localHasNext || ytContinuation !== '');
|
||||
|
||||
const nothingFound = $derived(
|
||||
!!data.query && items.length === 0 && !data.needsAuth && !data.ytError
|
||||
);
|
||||
const nothingFound = $derived(!!data.query && items.length === 0 && !data.ytError);
|
||||
|
||||
function buildHref(query: string) {
|
||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||
@@ -212,12 +210,7 @@
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{/if}
|
||||
|
||||
{#if data.needsAuth}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description="Inicia sesión para ver también resultados de YouTube Music."
|
||||
/>
|
||||
{:else if data.ytError}
|
||||
{#if data.ytError}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo."
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||
import { hueFor, fmtTime, fmtDate } from '$lib/theme/color';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import { fmtTime, fmtDate } from '$lib/format';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
youTubeThumbnailUrl,
|
||||
type ApiTrackLike
|
||||
} from '$lib/player/player.svelte';
|
||||
import { hueFor, fmtTime, fmtDate } from '$lib/theme/color';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import { fmtTime, fmtDate } from '$lib/format';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
type ApiTrackLike,
|
||||
type QueueItem
|
||||
} from '$lib/player/player.svelte';
|
||||
import { hueFor, fmtTime } from '$lib/theme/color';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
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';
|
||||
|
||||
@@ -52,18 +52,19 @@
|
||||
if (t < 1) rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
{#if isAuthPage}
|
||||
{#if isAuthPage || !data.user}
|
||||
<div class="min-h-screen bg-bg text-fg antialiased">
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen bg-bg text-fg antialiased">
|
||||
<Sidebar user={data.user!} />
|
||||
<Sidebar user={data.user} />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<main class="flex-1 transition-[padding] {hasTrack ? 'pb-[92px]' : 'pb-0'}">
|
||||
{#key page.url.pathname}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, playlistCoverTrackIds } from '$lib/server/api';
|
||||
import { createApiClient, playlistCoverTrackIds, requireUser } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
||||
import { pickGreeting } from '$lib/server/greeting';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const latestPromise = api.GET('/tracks', {
|
||||
params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
|
||||
});
|
||||
const playlistsPromise = locals.user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 12 } }
|
||||
})
|
||||
: Promise.resolve({ data: undefined });
|
||||
const recentlyPlayedPromise = locals.user
|
||||
? api.GET('/users/{id}/listening-history', { params: { path: { id: locals.user.sub } } })
|
||||
: Promise.resolve({ data: undefined });
|
||||
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } }
|
||||
});
|
||||
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
|
||||
params: { path: { id: user.sub } }
|
||||
});
|
||||
|
||||
const [latest, playlists, recentlyPlayed] = await Promise.all([
|
||||
latestPromise,
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
<section class="relative overflow-hidden page-x pt-11 pb-16">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 blur-[70px] saturate-150"
|
||||
style="background:linear-gradient(135deg, var(--mf-accent), color-mix(in oklch, var(--mf-accent), black 55%));animation:breathe 9s ease-in-out infinite"
|
||||
style="background:linear-gradient(135deg, var(--color-accent), color-mix(in oklch, var(--color-accent), black 55%));animation:breathe 9s ease-in-out infinite"
|
||||
></div>
|
||||
<div class="animate-enter relative flex flex-col gap-2.5">
|
||||
<p class="tracking-[0.14em] text-fg-2 uppercase">{data.greeting}</p>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--color-neutral-950: #08080a;
|
||||
|
||||
--color-bg: var(--mf-bg);
|
||||
--color-elevated: var(--mf-elevated);
|
||||
@@ -18,8 +17,10 @@
|
||||
--color-fg-3: var(--mf-text-3);
|
||||
--color-muted: var(--mf-text-4);
|
||||
--color-accent: var(--mf-accent);
|
||||
--color-accent-soft: var(--mf-accent-60);
|
||||
--color-on-accent: #ffffff;
|
||||
--color-accent-soft: var(--mf-accent-soft);
|
||||
--color-on-accent: var(--mf-on-art);
|
||||
--color-scrim: var(--mf-scrim);
|
||||
--color-on-art: var(--mf-on-art);
|
||||
--color-danger: oklch(63.7% 0.237 25.331);
|
||||
--color-danger-fg: oklch(88.5% 0.062 18.334);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="grid min-h-screen place-items-center bg-bg px-6 text-fg">
|
||||
<div class="flex w-full max-w-[440px] flex-col items-center gap-9 text-center">
|
||||
<div class="flex w-full max-w-110 flex-col items-center gap-9 text-center">
|
||||
<div class="text-3xl font-extrabold tracking-tight">Musify</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user