Redesign explore search and inline top-bar search box

- Explore: replace per-kind colored badges/tints in search results
  with the app's neutral accent, add an always-visible Usuarios chip,
  and keep the top-result type label as plain text
- Move SearchBox into TopBar/MobileHeader as an inline debounced
  search form, dropping the standalone SearchBox component
- Sync design handoff artboards with the current search UI
This commit is contained in:
2026-09-15 03:35:05 +02:00
parent dc1ba89a6d
commit 7861600081
10 changed files with 3844 additions and 4965 deletions
@@ -10,7 +10,6 @@
import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './ui/MenuItem.svelte';
import GlassMenu from './ui/GlassMenu.svelte';
import SearchBox from './SearchBox.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
interface Props {
@@ -29,92 +28,86 @@
</script>
<header
class="sticky top-0 z-40 flex flex-col gap-2.5 border-b border-hairline bg-bg/85 page-x pt-3 pb-3 backdrop-blur-md sm:hidden"
class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
>
<div class="flex h-9 items-center justify-between">
<a
href="/"
class="flex items-center gap-2 font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg"
>
<span
class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
></span>
Musify
</a>
<a
href="/"
class="flex items-center gap-2 font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg"
>
<span
class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
></span>
Musify
</a>
<div class="relative" bind:this={menuRef}>
{#if user}
<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 uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<GlassMenu class="absolute top-full right-0 mt-2 w-56 overflow-hidden">
<MenuItem
icon={User}
label="Ver perfil"
href="/u/{user.sub}"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
{#if accountUrl}
<MenuItem
icon={Settings}
label="Ajustes"
href={accountUrl}
target="_blank"
onclick={() => (menuOpen = false)}
/>
{/if}
<MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()}
/>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
</form>
</GlassMenu>
<div class="relative" bind:this={menuRef}>
{#if user}
<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 uppercase">
{user.name.charAt(0)}
</span>
{/if}
{:else}
<a
href="/auth/login"
data-sveltekit-reload
aria-label="Iniciar sesión"
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
>
<LogIn class="h-6 w-6" strokeWidth={2} />
</a>
{/if}
</div>
</div>
</button>
<SearchBox class="w-full" />
{#if menuOpen}
<GlassMenu class="absolute top-full right-0 mt-2 w-56 overflow-hidden">
<MenuItem
icon={User}
label="Ver perfil"
href="/u/{user.sub}"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
{#if accountUrl}
<MenuItem
icon={Settings}
label="Ajustes"
href={accountUrl}
target="_blank"
onclick={() => (menuOpen = false)}
/>
{/if}
<MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()}
/>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
</form>
</GlassMenu>
{/if}
{:else}
<a
href="/auth/login"
data-sveltekit-reload
aria-label="Iniciar sesión"
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
>
<LogIn class="h-6 w-6" strokeWidth={2} />
</a>
{/if}
</div>
</header>
<svelte:window onclick={onDocumentClick} />
@@ -1,86 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
interface Props {
class?: string;
placeholder?: string;
}
let { class: klass = '', placeholder = 'Canciones, artistas, álbumes o playlists' }: Props =
$props();
let query = $state('');
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function navigateSearch() {
const term = query.trim();
goto(term ? `/explore?q=${encodeURIComponent(term)}` : '/explore', {
replaceState: page.url.pathname === '/explore',
keepFocus: true,
noScroll: true
});
}
function onInput() {
clearTimeout(searchTimeout);
const term = query.trim();
const onExplore = page.url.pathname === '/explore';
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
if (term.length === 0 && !onExplore) return;
searchTimeout = setTimeout(navigateSearch, SEARCH_DEBOUNCE_MS);
}
function clearQuery() {
clearTimeout(searchTimeout);
query = '';
if (page.url.pathname === '/explore') navigateSearch();
searchInput?.focus();
}
function onSubmit(event: SubmitEvent) {
event.preventDefault();
clearTimeout(searchTimeout);
navigateSearch();
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
event.preventDefault();
clearTimeout(searchTimeout);
navigateSearch();
}
</script>
<div class="relative min-w-0 {klass}">
<form onsubmit={onSubmit}>
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-white/[3.5%] px-3.5 transition focus-within:bg-white/[6%]"
>
<Search class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
onkeydown={onKeydown}
{placeholder}
class="w-full min-w-0 bg-transparent text-[13px] text-fg placeholder:text-muted focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
</button>
{/if}
</label>
</form>
</div>
+66 -2
View File
@@ -1,5 +1,10 @@
<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 Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import LogOut from '@lucide/svelte/icons/log-out';
@@ -9,7 +14,6 @@
import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './ui/MenuItem.svelte';
import GlassMenu from './ui/GlassMenu.svelte';
import SearchBox from './SearchBox.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
interface Props {
@@ -19,8 +23,44 @@
let { user, accountUrl }: Props = $props();
let query = $derived(
page.url.pathname === '/explore' ? (page.url.searchParams.get('q') ?? '') : ''
);
let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state();
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function buildHref(term: string) {
return term ? `/explore?q=${encodeURIComponent(term)}` : '/explore';
}
function runSearch(term: string) {
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
goto(buildHref(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());
}
function onDocumentClick(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
@@ -45,7 +85,31 @@
</span>
</div>
<SearchBox class="max-w-[480px] flex-1" />
<form onsubmit={onSubmit} class="max-w-[480px] min-w-0 flex-1">
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-white/[3.5%] px-3.5 transition focus-within:bg-white/[6%]"
>
<Search class="h-[15px] w-[15px] 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-[13px] text-fg placeholder:text-muted focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
</button>
{/if}
</label>
</form>
<div class="flex-1"></div>
@@ -0,0 +1,80 @@
<script lang="ts" module>
export type SearchResultKind = 'track' | 'album' | 'playlist' | 'user';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
const RADIUS: Record<SearchResultKind, string> = {
track: 'rounded-control',
album: 'rounded-[6px]',
playlist: 'rounded-[13px]',
user: 'rounded-full'
};
interface Props {
kind: SearchResultKind;
title: string;
subtitle?: string | null;
meta?: string;
explicit?: boolean;
href?: string;
onclick?: () => void;
oncontextmenu?: (event: MouseEvent) => void;
art: Snippet<[string]>;
}
let {
kind,
title,
subtitle,
meta,
explicit = false,
href,
onclick,
oncontextmenu,
art
}: Props = $props();
const artClass = $derived(
`relative h-13 w-13 shrink-0 overflow-hidden shadow-art ${RADIUS[kind]}`
);
</script>
{#snippet content()}
{@render art(artClass)}
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.75">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-[13px] font-medium text-fg">{title}</span>
</div>
{#if subtitle}
<div class="mt-0.75 truncate text-[11.5px] text-fg-3">{subtitle}</div>
{/if}
</div>
{#if meta}
<span class="shrink-0 text-[11.5px] text-muted tabular-nums">{meta}</span>
{/if}
{/snippet}
{#if href}
<a
{href}
{oncontextmenu}
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface"
>
{@render content()}
</a>
{:else}
<button
type="button"
{onclick}
{oncontextmenu}
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
{@render content()}
</button>
{/if}
@@ -12,7 +12,7 @@
</script>
{#snippet heading()}
<h2 class="font-display text-[17.5px] font-semibold tracking-[-0.025em] text-fg">
<h2 class="font-display text-[17.5px] font-medium tracking-[-0.025em] text-fg">
{title}
</h2>
{#if subtitle}