Responsive design

This commit is contained in:
2026-07-20 16:25:44 +02:00
parent 589864e48f
commit ca24632fac
30 changed files with 346 additions and 112 deletions
@@ -0,0 +1,80 @@
<script lang="ts">
import type { SessionUser } from '$lib/types';
import Upload from '@lucide/svelte/icons/upload';
import Folder from '@lucide/svelte/icons/folder';
import LogOut from '@lucide/svelte/icons/log-out';
interface Props {
user: SessionUser;
}
let { user }: Props = $props();
let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state();
function onDocumentClick(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
}
</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"
>
<a href="/" class="text-lg font-extrabold tracking-wide">Musify</a>
<div class="relative" bind:this={menuRef}>
<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 font-semibold 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 py-1.5 shadow-menu"
>
<a
href="/library"
onclick={() => (menuOpen = false)}
class="flex w-full items-center gap-3 px-3 py-2.5 text-base font-semibold text-fg-2 transition hover:bg-hover"
>
<Folder class="h-[18px] w-[18px]" strokeWidth={2} />
Canciones subidas
</a>
<a
href="/upload"
onclick={() => (menuOpen = false)}
class="flex w-full items-center gap-3 px-3 py-2.5 text-base font-semibold text-fg-2 transition hover:bg-hover"
>
<Upload class="h-[18px] w-[18px]" strokeWidth={2} />
Subir música
</a>
<div class="my-1.5 border-t border-line"></div>
<form method="POST" action="/logout" data-sveltekit-reload>
<button
type="submit"
class="flex w-full items-center gap-3 px-3 py-2.5 text-base font-semibold text-fg-2 transition hover:bg-hover"
>
<LogOut class="h-[18px] w-[18px]" strokeWidth={2} />
Salir
</button>
</form>
</div>
{/if}
</div>
</header>
<svelte:window onclick={onDocumentClick} />
@@ -0,0 +1,41 @@
<script lang="ts">
import { page } from '$app/state';
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';
const links = [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search },
{ href: '/playlists', label: 'Listas', icon: Music },
{ href: '/library', label: 'Subidas', icon: Folder }
];
function isActive(href: string) {
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
}
</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)] grid-cols-4">
{#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>
+5 -5
View File
@@ -75,7 +75,7 @@
{/snippet}
<aside
class="sticky top-0 flex h-screen flex-shrink-0 flex-col gap-7 border-r border-line px-[18px] py-[26px]"
class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="px-3 text-xl font-extrabold tracking-wide">Musify</a>
@@ -83,11 +83,11 @@
<div class="relative flex flex-col gap-7" bind:this={navBox}>
<div
class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-control bg-surface-hover {animate
? 'transition-[top,height] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]'
? '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-[3px] rounded-r-full bg-accent-soft"></span>
<span class="absolute inset-y-0 left-0 w-0.75 rounded-r-full bg-accent-soft"></span>
</div>
<nav class="relative z-10 flex flex-col gap-1">
@@ -115,7 +115,7 @@
type="submit"
class="flex w-full items-center gap-3 px-3 py-2.5 text-base font-semibold text-fg-2 transition hover:bg-hover"
>
<LogOut class="h-[18px] w-[18px]" strokeWidth={2} />
<LogOut class="h-4.5 w-4.5" strokeWidth={2} />
Salir
</button>
</form>
@@ -130,7 +130,7 @@
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span
class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-semibold uppercase"
class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-semibold uppercase"
>
{user.name.charAt(0)}
</span>
@@ -3,15 +3,15 @@
export type ContextMenuState = TrackTarget & { x: number; y: number; openLeft: boolean };
const MENU_WIDTH = 208;
const SUBMENU_WIDTH = 224;
const MENU_WIDTH = 240;
const SUBMENU_WIDTH = 256;
export function contextMenuStateFor(event: MouseEvent, target: TrackTarget): ContextMenuState {
event.preventDefault();
return {
...target,
x: Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8),
y: Math.min(event.clientY, window.innerHeight - 60),
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
@@ -70,12 +70,12 @@
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 {menu.openLeft
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu"
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu max-sm:w-full"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
@@ -107,7 +107,7 @@
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-8 w-8 flex-shrink-0 rounded-control"
class="h-8 w-8 shrink-0 rounded-control"
/>
<span class="truncate text-base text-fg-2">{playlist.name}</span>
</button>
@@ -6,11 +6,22 @@
</script>
<div
class="animate-enter fixed right-0 bottom-0 z-50 h-[92px] border-t border-line bg-bg"
style="left:var(--mf-sidebar-w)"
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))"
>
<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="grid h-full items-center gap-5 px-8"
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)"
>
<TrackInfo />
@@ -2,32 +2,52 @@
import { player } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
interface Props {
compact?: boolean;
}
let { compact = false }: Props = $props();
function seek(event: MouseEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
player.seekFraction((event.clientX - rect.left) / rect.width);
}
</script>
<div class="flex w-full max-w-172 items-center gap-3">
<span class="text-right text-xs text-muted tabular-nums">
{fmtTime(player.progress)}
</span>
{#if compact}
<button
type="button"
onclick={seek}
aria-label="Barra de progreso"
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track transition-[height] hover:h-2"
class="relative block h-1 w-full cursor-pointer bg-track"
>
<div
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
class="absolute inset-y-0 left-0 bg-accent/70"
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>
</button>
<span class="text-xs text-muted tabular-nums">
{fmtTime(player.current.duration)}
</span>
</div>
{:else}
<div class="flex w-full max-w-172 items-center gap-3">
<span class="text-right text-xs text-muted 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 transition-[height] hover:h-2"
>
<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>
</button>
<span class="text-xs text-muted tabular-nums">
{fmtTime(player.current.duration)}
</span>
</div>
{/if}
@@ -4,7 +4,7 @@
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
</script>
<div class="flex min-w-0 items-center gap-3">
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
{#if player.current.id}
<Cover
trackId={player.current.id}
@@ -12,11 +12,11 @@
hue={player.current.hue}
size="small"
alt={player.current.title}
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
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"
/>
{:else}
<div
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
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"
style="background:{player.artGradient}"
></div>
{/if}
@@ -1,14 +1,21 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
interface Props {
compact?: boolean;
}
let { compact = false }: Props = $props();
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'
);
</script>
<div class="flex items-center gap-5">
<button
type="button"
onclick={() => player.previous()}
aria-label="Anterior"
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
>
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-5'}">
<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" />
<path d="M15 0 5 7l10 7z" />
@@ -19,7 +26,7 @@
type="button"
onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
class="grid h-[36px] w-[36px] place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
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"
>
{#if player.playing}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
@@ -33,12 +40,7 @@
{/if}
</button>
<button
type="button"
onclick={() => player.next()}
aria-label="Siguiente"
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
>
<button type="button" onclick={() => player.next()} aria-label="Siguiente" class={sideClass}>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<path d="M1 0 11 7 1 14z" />
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
@@ -35,10 +35,10 @@
};
const variants = {
primary: 'bg-accent-soft font-semibold text-on-accent active:scale-[0.97]',
primary: 'bg-accent-soft 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-on-accent hover:brightness-110'
subtle: 'bg-surface-2 text-fg hover:bg-surface-hover',
danger: 'bg-danger text-on-accent hover:brightness-110'
};
const classes = $derived(
@@ -31,7 +31,9 @@
title={label}
class="grid place-items-center rounded-control text-muted transition hover:bg-hover hover:text-fg {sizes[
size
]} {revealOnHover ? 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100' : ''} {klass}"
]} {revealOnHover
? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100'
: ''} {klass}"
>
{@render children()}
</button>
@@ -10,7 +10,8 @@
let {
name,
fallbackUrl,
class: klass = 'h-52 w-52 rounded-control border border-line bg-surface hover:border-accent/50'
class:
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
}: Props = $props();
let preview = $state('');
@@ -4,17 +4,24 @@
interface Props {
as?: 'ul' | 'div';
min?: string;
minMobile?: string;
class?: string;
children: Snippet;
}
let { as = 'div', min = '180px', class: klass = '', children }: Props = $props();
let {
as = 'div',
min = '180px',
minMobile = '150px',
class: klass = '',
children
}: Props = $props();
</script>
<svelte:element
this={as}
class="grid gap-4 {klass}"
style="grid-template-columns:repeat(auto-fill,minmax({min},1fr))"
class="grid media-grid gap-3 sm:gap-4 {klass}"
style="--mf-grid-min:{min}; --mf-grid-min-mobile:{minMobile}"
>
{@render children()}
</svelte:element>
@@ -74,7 +74,7 @@
>
<div
bind:this={panel}
class="animate-pop w-full {maxWidth} rounded-panel p-6 shadow-2xl outline-none {panelClass}"
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl outline-none sm:p-6 {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
+1 -1
View File
@@ -9,6 +9,6 @@
let { class: klass = '', children }: Props = $props();
</script>
<section class="page-x pt-11 pb-10 {klass}">
<section class="page-x pt-7 pb-8 sm:pt-11 sm:pb-10 {klass}">
{@render children()}
</section>
@@ -11,11 +11,11 @@
let { title, subtitle, class: klass = '', actions }: Props = $props();
</script>
<div class="flex items-end justify-between gap-4 {klass}">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
<div class="min-w-0">
<h1 class="text-3xl font-bold tracking-tight text-fg sm:text-4xl">{title}</h1>
<h1 class="text-2xl font-bold tracking-tight text-fg sm:text-4xl">{title}</h1>
{#if subtitle}
<p class="mt-2 text-fg-2">{subtitle}</p>
<p class="mt-2 text-sm text-fg-2 sm:text-base">{subtitle}</p>
{/if}
</div>
{#if actions}
@@ -53,7 +53,7 @@
}}
class="mt-4 w-full max-w-4xl space-y-4"
>
<div class="flex items-stretch gap-8">
<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>
@@ -7,4 +7,4 @@
let { title, class: klass = '' }: Props = $props();
</script>
<h2 class="mb-6 text-2xl font-bold text-fg {klass}">{title}</h2>
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2>
@@ -9,7 +9,7 @@
let { padding = 'md', class: klass = '', children }: Props = $props();
const paddings = { sm: 'p-4', md: 'p-6', lg: 'p-12' };
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}">
@@ -16,8 +16,8 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid items-center gap-8 rounded-control px-3 py-2.5 transition hover:bg-surface {klass}"
style="grid-template-columns:{table.columns}"
class="group grid track-grid items-center gap-3 rounded-control px-2 py-2.5 transition hover:bg-surface sm:gap-8 sm:px-3 {klass}"
style="--mf-track-cols:{table.columns}; --mf-track-cols-mobile:{table.columnsMobile}"
{oncontextmenu}
>
{@render children()}
@@ -3,6 +3,7 @@
export interface TrackTableContext {
readonly columns: string;
readonly columnsMobile: string;
}
</script>
@@ -12,24 +13,30 @@
interface Props {
columns: string;
columnsMobile?: string;
class?: string;
headers: Snippet;
children: Snippet;
}
let { columns, class: klass = '', headers, children }: Props = $props();
let { columns, columnsMobile, class: klass = '', headers, children }: Props = $props();
const mobile = $derived(columnsMobile ?? columns);
setContext<TrackTableContext>(trackTableKey, {
get columns() {
return columns;
},
get columnsMobile() {
return mobile;
}
});
</script>
<div class={klass}>
<div
class="grid items-center gap-8 border-b border-line px-3 pb-2 text-sm font-medium tracking-wide text-muted uppercase"
style="grid-template-columns:{columns}"
class="grid track-grid items-center gap-3 border-b border-line px-2 pb-2 text-sm font-medium tracking-wide text-muted uppercase sm:gap-8 sm:px-3"
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{mobile}"
>
{@render headers()}
</div>
@@ -39,7 +39,7 @@
{hue}
size="small"
alt={title}
class="{coverSize} flex-shrink-0 rounded-control"
class="{coverSize} shrink-0 rounded-control"
>
{@render overlay?.()}
</Cover>
+11 -1
View File
@@ -21,5 +21,15 @@
--mf-scrim: #000000;
--mf-on-art: #ffffff;
--mf-sidebar-w: 264px;
--mf-sidebar-w: 288px;
--mf-nav-h: 60px;
--mf-player-h: 64px;
--mf-safe-b: env(safe-area-inset-bottom, 0px);
}
@media (width >= 48rem) {
:root {
--mf-nav-h: 0px;
--mf-player-h: 92px;
}
}