Design Prot Merge #1

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