feat(web-player): filter the explore results with a chip group
Under the search box there are now three checkbox chips choosing what the page shows. Songs keep the existing grid and Albums searches the albums by title; Users is rendered disabled until there is something behind it.
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
|
|
||||||
|
export interface ChipOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
disabled?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Check from '@lucide/svelte/icons/check';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: ChipOption[];
|
||||||
|
selected: string[];
|
||||||
|
label: string;
|
||||||
|
onChange: (selected: string[]) => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { options, selected, label, onChange, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
|
function toggle(value: string) {
|
||||||
|
onChange(selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="group" aria-label={label} class="flex flex-wrap items-center gap-2 {klass}">
|
||||||
|
{#each options as option (option.value)}
|
||||||
|
{@const active = selected.includes(option.value)}
|
||||||
|
<label
|
||||||
|
title={option.hint}
|
||||||
|
class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-sm font-semibold transition select-none
|
||||||
|
{active
|
||||||
|
? 'border-transparent bg-accent text-on-accent'
|
||||||
|
: 'border-line bg-surface text-fg-2 hover:bg-surface-hover'}
|
||||||
|
{option.disabled ? 'pointer-events-none opacity-45' : ''}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only"
|
||||||
|
checked={active}
|
||||||
|
disabled={option.disabled}
|
||||||
|
onchange={() => toggle(option.value)}
|
||||||
|
/>
|
||||||
|
{#if active}
|
||||||
|
<Check class="h-3.5 w-3.5" strokeWidth={3} />
|
||||||
|
{:else if option.icon}
|
||||||
|
<option.icon class="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
|
{/if}
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -6,5 +6,6 @@ export const ALBUMS_PAGE_SIZE = 50;
|
|||||||
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
||||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||||
|
export const EXPLORE_ALBUMS_PAGE_SIZE = 18;
|
||||||
export const HOME_LATEST_PAGE_SIZE = 12;
|
export const HOME_LATEST_PAGE_SIZE = 12;
|
||||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 {
|
import {
|
||||||
|
EXPLORE_ALBUMS_PAGE_SIZE,
|
||||||
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
||||||
SEARCH_MIN_LENGTH,
|
SEARCH_MIN_LENGTH,
|
||||||
YOUTUBE_FILLER_LIMIT
|
YOUTUBE_FILLER_LIMIT
|
||||||
@@ -28,10 +29,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const [tracksRes, searchRes, playlistsRes] = await Promise.all([
|
const albumsPromise = query
|
||||||
|
? api.GET('/albums', {
|
||||||
|
params: { query: { title: query, pageNumber: 1, pageSize: EXPLORE_ALBUMS_PAGE_SIZE } }
|
||||||
|
})
|
||||||
|
: Promise.resolve(null);
|
||||||
|
|
||||||
|
const [tracksRes, searchRes, playlistsRes, albumsRes] = await Promise.all([
|
||||||
tracksPromise,
|
tracksPromise,
|
||||||
searchPromise,
|
searchPromise,
|
||||||
playlistsPromise
|
playlistsPromise,
|
||||||
|
albumsPromise
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
||||||
@@ -39,6 +47,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
tracks,
|
tracks,
|
||||||
|
albums: albumsRes?.data?.items ?? [],
|
||||||
ytResults: searchRes?.data ?? null,
|
ytResults: searchRes?.data ?? null,
|
||||||
ytError: Boolean(searchRes?.error),
|
ytError: Boolean(searchRes?.error),
|
||||||
youtubeFiller: query
|
youtubeFiller: query
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
|
import Disc from '@lucide/svelte/icons/disc-3';
|
||||||
|
import Users from '@lucide/svelte/icons/users';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
@@ -11,6 +13,9 @@
|
|||||||
import Input from '$lib/components/ui/Input.svelte';
|
import Input from '$lib/components/ui/Input.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
||||||
|
import ChipGroup, { type ChipOption } from '$lib/components/ui/ChipGroup.svelte';
|
||||||
|
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
|
||||||
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import { EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
|
import { EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
|
||||||
import { hueFor } from '$lib/theme/color';
|
import { hueFor } from '$lib/theme/color';
|
||||||
import type { YouTubeSong } from '$lib/types';
|
import type { YouTubeSong } from '$lib/types';
|
||||||
@@ -36,6 +41,17 @@
|
|||||||
let searchValue = $state(untrack(() => data.query));
|
let searchValue = $state(untrack(() => data.query));
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const filterOptions: ChipOption[] = [
|
||||||
|
{ value: 'tracks', label: 'Canciones', icon: Music },
|
||||||
|
{ value: 'albums', label: 'Álbumes', icon: Disc },
|
||||||
|
{ value: 'users', label: 'Usuarios', icon: Users, disabled: true, hint: 'Próximamente' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let filters = $state<string[]>(['tracks', 'albums']);
|
||||||
|
|
||||||
|
const showTracks = $derived(filters.includes('tracks'));
|
||||||
|
const showAlbums = $derived(filters.includes('albums'));
|
||||||
|
|
||||||
let ytItems = $state<YouTubeSong[]>([]);
|
let ytItems = $state<YouTubeSong[]>([]);
|
||||||
let ytContinuation = $state('');
|
let ytContinuation = $state('');
|
||||||
let ytSearchTerm = $state('');
|
let ytSearchTerm = $state('');
|
||||||
@@ -82,9 +98,17 @@
|
|||||||
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
|
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasMore = $derived(localHasNext || ytContinuation !== '');
|
const hasMore = $derived(showTracks && (localHasNext || ytContinuation !== ''));
|
||||||
|
|
||||||
const nothingFound = $derived(!!data.query && items.length === 0 && !data.ytError);
|
const albums = $derived(data.albums);
|
||||||
|
|
||||||
|
const nothingFound = $derived(
|
||||||
|
!!data.query &&
|
||||||
|
!data.ytError &&
|
||||||
|
(showTracks ? items.length === 0 : true) &&
|
||||||
|
(showAlbums ? albums.length === 0 : true) &&
|
||||||
|
filters.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
function buildHref(query: string) {
|
function buildHref(query: string) {
|
||||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||||
@@ -176,8 +200,24 @@
|
|||||||
oninput={onSearchInput}
|
oninput={onSearchInput}
|
||||||
placeholder="Buscar en tu música y en YouTube Music..."
|
placeholder="Buscar en tu música y en YouTube Music..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ChipGroup
|
||||||
|
label="Qué quieres buscar"
|
||||||
|
options={filterOptions}
|
||||||
|
selected={filters}
|
||||||
|
onChange={(next) => (filters = next)}
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if filters.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
icon={Search}
|
||||||
|
title="No has elegido qué buscar"
|
||||||
|
description="Marca al menos un tipo de resultado para verlos aquí."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
<Alert class="mt-4">{form.message}</Alert>
|
<Alert class="mt-4">{form.message}</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -190,7 +230,28 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if showAlbums && albums.length > 0}
|
||||||
|
<div class="mt-8">
|
||||||
|
<SectionHeading title="Álbumes" />
|
||||||
|
<MediaGrid class="mt-4">
|
||||||
|
{#each albums as album, i (album.id)}
|
||||||
|
<AlbumCard
|
||||||
|
id={album.id}
|
||||||
|
title={album.title}
|
||||||
|
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||||
|
trackCount={Number(album.trackCount)}
|
||||||
|
trackIds={[]}
|
||||||
|
index={i}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</MediaGrid>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showTracks && items.length > 0}
|
||||||
|
{#if showAlbums && albums.length > 0}
|
||||||
|
<SectionHeading title="Canciones" class="mt-10" />
|
||||||
|
{/if}
|
||||||
<MediaGrid as="ul" class="mt-6">
|
<MediaGrid as="ul" class="mt-6">
|
||||||
{#each items as item, i (targetId(item))}
|
{#each items as item, i (targetId(item))}
|
||||||
<TrackTile
|
<TrackTile
|
||||||
|
|||||||
Reference in New Issue
Block a user