Move app chrome components into components/layout

This commit is contained in:
2026-09-22 14:12:11 +02:00
parent fe2f6a785a
commit 50618865e7
6 changed files with 16 additions and 16 deletions
@@ -0,0 +1,50 @@
<script lang="ts">
import type { SessionUser } from '$lib/types';
import Upload from '@lucide/svelte/icons/upload';
import Folder from '@lucide/svelte/icons/folder';
import LogIn from '@lucide/svelte/icons/log-in';
import MenuItem from '../ui/MenuItem.svelte';
import AccountMenu from '../ui/AccountMenu.svelte';
import Avatar from '../ui/Avatar.svelte';
import IconButton from '../ui/IconButton.svelte';
import Logo from '../ui/Logo.svelte';
interface Props {
user: SessionUser | null;
accountUrl?: string | null;
}
let { user, accountUrl }: Props = $props();
</script>
<header
class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
>
<a href="/">
<Logo size="sm" />
</a>
{#if user}
<AccountMenu {user} {accountUrl} width="md">
{#snippet trigger({ toggle, open })}
<button
type="button"
onclick={toggle}
aria-label="Tu cuenta"
aria-expanded={open}
class="flex items-center rounded-full"
>
<Avatar name={user.name} src={user.picture} size="sm" />
</button>
{/snippet}
{#snippet extraItems({ close })}
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
{/snippet}
</AccountMenu>
{:else}
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
<LogIn class="size-icon-lg" strokeWidth={2} />
</IconButton>
{/if}
</header>
@@ -0,0 +1,102 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/state/playlists.svelte';
import type { SessionUser } from '$lib/types';
import ListRow from '../ui/ListRow.svelte';
import IconButton from '../ui/IconButton.svelte';
import Logo from '../ui/Logo.svelte';
import Plus from '@lucide/svelte/icons/plus';
interface SidebarPlaylist {
id: string;
name: string;
coverTrackIds: (string | number)[];
updatedAt: string;
}
interface Props {
user: SessionUser | null;
playlists: SidebarPlaylist[];
}
let { user, playlists }: Props = $props();
const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count }));
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script>
<aside
class="hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 theme-transition lg:flex"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="px-3 pb-3">
<Logo size="sm" />
</a>
<nav class="flex flex-col gap-1">
{#each navLinks as link (link.href)}
{@const active = isActive(link.href)}
<a
href={link.href}
aria-current={active ? 'page' : undefined}
class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors duration-150 {active
? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
>
<link.icon class="size-4.5 shrink-0" strokeWidth={1.5} />
<span class="flex-1 truncate">{link.label}</span>
{#if link.count}
<span class="text-xs text-muted tabular-nums">{link.count}</span>
{/if}
</a>
{/each}
</nav>
{#if user}
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-eyebrow text-muted">Tus playlists</span>
{#if playlists.length > 0}
<IconButton
label="Crear playlist"
tone="plain"
size="xs"
onclick={() => createPlaylistModal.show()}
>
<Plus class="size-icon-sm" strokeWidth={1.25} />
</IconButton>
{/if}
</div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
{#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
<ListRow
href="/playlists/{playlist.id}"
{active}
size="xs"
title={playlist.name}
coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
class="px-3 py-2"
/>
{:else}
<button
type="button"
onclick={() => createPlaylistModal.show()}
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-muted transition-colors hover:bg-hover hover:text-fg-2"
>
<Plus class="size-3.5" strokeWidth={1.8} />
Crear tu primera lista
</button>
{/each}
</div>
{/if}
</aside>
@@ -0,0 +1,37 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
import type { SessionUser } from '$lib/types';
interface Props {
user: SessionUser | null;
class?: string;
}
let { user, class: klass = '' }: Props = $props();
const links = $derived(appNavLinks(user).filter((link) => link.primary));
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script>
<nav
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}"
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
>
{#each links as link (link.href)}
{@const active = isActive(link.href)}
<a
href={link.href}
aria-current={active ? 'page' : undefined}
class="flex flex-col items-center gap-1 rounded-control px-2.5 py-1.5 {active
? 'text-fg'
: 'text-fg-2'}"
>
<link.icon class="size-4" strokeWidth={1.6} />
<span class="text-xs font-medium">{link.label}</span>
</a>
{/each}
</nav>
@@ -0,0 +1,122 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { SessionUser } from '$lib/types';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import { searchHref } from '$lib/state/navigation.svelte';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import AccountMenu from '../ui/AccountMenu.svelte';
import Avatar from '../ui/Avatar.svelte';
import IconButton from '../ui/IconButton.svelte';
import Button from '../ui/Button.svelte';
interface Props {
user: SessionUser | null;
accountUrl?: string | null;
}
let { user, accountUrl }: Props = $props();
let query = $derived(
page.url.pathname === '/explore' ? (page.url.searchParams.get('q') ?? '') : ''
);
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function runSearch(term: string) {
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
goto(searchHref(term), {
replaceState: page.url.pathname === '/explore',
keepFocus: true,
noScroll: true
});
}
function onInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
}
function clearQuery() {
query = '';
clearTimeout(searchTimeout);
runSearch('');
searchInput?.focus();
}
function onSubmit(event: SubmitEvent) {
event.preventDefault();
clearTimeout(searchTimeout);
runSearch(query.trim());
}
</script>
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
<IconButton
label="Atrás"
shape="round"
tone="subtle"
surface
size="xs"
onclick={() => history.back()}
>
<ChevronLeft class="size-icon-sm" strokeWidth={1.9} />
</IconButton>
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
>
<Search class="size-4 shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
placeholder="Canciones, artistas, álbumes o playlists"
class="w-full min-w-0 bg-transparent text-sm text-fg placeholder:text-muted focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid size-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
>
<X class="size-3.5" />
</button>
{/if}
</label>
</form>
<div class="flex-1"></div>
<div class="shrink-0">
{#if user}
<AccountMenu {user} {accountUrl}>
{#snippet trigger({ toggle, open })}
<button
type="button"
onclick={toggle}
aria-label="Tu cuenta"
aria-expanded={open}
class="block shrink-0 rounded-full transition active:scale-95"
>
<Avatar
name={user.name}
src={user.picture}
size="sm"
class="border border-line-strong opacity-90"
/>
</button>
{/snippet}
</AccountMenu>
{:else}
<Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0"
>Iniciar sesión</Button
>
{/if}
</div>
</header>