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-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'}"
+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;
}
export function isYouTubeTrack(track: ApiTrackLike): boolean {
function isYouTubeTrack(track: ApiTrackLike): boolean {
return track.source === 'YouTube';
}
+2 -2
View File
@@ -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)];
}
-17
View File
@@ -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));
}
+5 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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';
+3 -2
View File
@@ -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}
+9 -10
View File
@@ -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,
+1 -1
View File
@@ -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>
+4 -3
View File
@@ -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);
+1 -1
View File
@@ -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>