feat(front): [WIP] improve search modal

This commit is contained in:
upsiflu 2025-03-22 17:29:57 +01:00
parent 7902c43702
commit 6d7b764786
2 changed files with 155 additions and 94 deletions

View File

@ -36,10 +36,6 @@ const { isOpen, value: query } = useModal(
isOn: (value) => value !== undefined && value !== '' isOn: (value) => value !== undefined && value !== ''
}) })
const startRadio = () => {
// TODO: Start the radio
}
// TODO: // TODO:
// - Limit search results to 4 // - Limit search results to 4
// - Add Link to specific search pages in each section where it applies // - Add Link to specific search pages in each section where it applies
@ -64,10 +60,7 @@ const startRadio = () => {
*/ */
const page = ref<number>(1) // Search query
const paginateBy = ref<number>(4)
// Search
const queryDebounced = refDebounced(query, 500) const queryDebounced = refDebounced(query, 500)
const trimmedQuery = computed(() => trim(trim(queryDebounced.value), '@')) const trimmedQuery = computed(() => trim(trim(queryDebounced.value), '@'))
@ -80,27 +73,37 @@ const isLoading = ref(false)
type Category = 'artists' | 'albums' | 'tracks' | 'playlists' | 'tags' | 'radios' | 'podcasts' | 'series' | 'rss' | 'federation' type Category = 'artists' | 'albums' | 'tracks' | 'playlists' | 'tags' | 'radios' | 'podcasts' | 'series' | 'rss' | 'federation'
type Results = { type SearchResponse = paths['/api/v2/search']['get']['responses']['200']['content']['application/json']
artists: paths['/api/v2/search']['get']['responses']['200']['content']['application/json']['artists'],
albums: paths['/api/v2/search']['get']['responses']['200']['content']['application/json']['albums'],
tracks: paths['/api/v2/search']['get']['responses']['200']['content']['application/json']['tracks'],
tags: paths['/api/v2/search']['get']['responses']['200']['content']['application/json']['tags'],
playlists: Response['playlists']['results'],
radios: Response['radios']['results'],
podcasts: Response['podcasts']['results'],
series: Response['series']['results'],
rss: paths['/api/v2/channels/rss-subscribe/']['post']['responses']['200']['content']['application/json'],
federation: paths['/api/v2/federation/fetches/']['post']['responses']['201']['content']['application/json']
}
type Response = { type Response = {
artists: SearchResponse,
albums: SearchResponse,
tracks: SearchResponse,
tags: SearchResponse,
playlists: paths['/api/v2/playlists/']['get']['responses']['200']['content']['application/json'], playlists: paths['/api/v2/playlists/']['get']['responses']['200']['content']['application/json'],
radios: paths['/api/v2/radios/radios/']['get']['responses']['200']['content']['application/json'], radios: paths['/api/v2/radios/radios/']['get']['responses']['200']['content']['application/json'],
podcasts: paths['/api/v2/artists/']['get']['responses']['200']['content']['application/json'], podcasts: paths['/api/v2/artists/']['get']['responses']['200']['content']['application/json'],
series: paths['/api/v2/albums/']['get']['responses']['200']['content']['application/json'], series: paths['/api/v2/albums/']['get']['responses']['200']['content']['application/json'],
rss: paths['/api/v2/channels/rss-subscribe/']['post']['responses']['200']['content']['application/json'],
federation: paths['/api/v2/federation/fetches/']['post']['responses']['201']['content']['application/json']
} }
const results = ref<Partial<Results>>() /** Note that `federation` is a singleton list so that each result is a list */
type Results = {
artists: SearchResponse['artists'],
albums: SearchResponse['albums'],
tracks: SearchResponse['tracks'],
tags: SearchResponse['tags'],
playlists: Response['playlists']['results'],
radios: Response['radios']['results'],
podcasts: Response['podcasts']['results'],
series: Response['series']['results'],
rss: [Response['rss']],
federation: [Response['federation']]
}
const responses = ref<Partial<Response>>({})
const results = ref<Partial<Results>>({})
const categories = computed(() => [ const categories = computed(() => [
{ {
@ -110,7 +113,9 @@ const categories = computed(() => [
endpoint: '/search', endpoint: '/search',
params: { params: {
contentCategory: 'music', contentCategory: 'music',
includeChannels: 'true' includeChannels: 'true',
page: 1,
page_size: 4
} }
}, },
{ {
@ -119,19 +124,29 @@ const categories = computed(() => [
more: '/library/albums', more: '/library/albums',
endpoint: '/search', endpoint: '/search',
params: { params: {
contentCategory: 'music',
includeChannels: 'true', includeChannels: 'true',
contentCategory: 'music' page: 1,
page_size: 4
} }
}, },
{ {
type: 'tracks', type: 'tracks',
label: t('views.Search.label.tracks'), label: t('views.Search.label.tracks'),
endpoint: '/search' endpoint: '/search',
params: {
page: 1,
page_size: 24
}
}, },
{ {
type: 'tags', type: 'tags',
label: t('views.Search.label.tags'), label: t('views.Search.label.tags'),
endpoint: '/search' endpoint: '/search',
params: {
page: 1,
page_size: 24
}
}, },
{ {
type: 'playlists', type: 'playlists',
@ -143,7 +158,11 @@ const categories = computed(() => [
type: 'radios', type: 'radios',
label: t('views.Search.label.radios'), label: t('views.Search.label.radios'),
more: '/library/radios', more: '/library/radios',
endpoint: '/radios/radios/' endpoint: '/radios/radios/',
params: {
page: 1,
page_size: 4
}
}, },
{ {
type: 'podcasts', type: 'podcasts',
@ -152,7 +171,9 @@ const categories = computed(() => [
endpoint: '/artists/', endpoint: '/artists/',
params: { params: {
contentCategory: 'podcast', contentCategory: 'podcast',
includeChannels: 'true' includeChannels: 'true',
page: 1,
page_size: 4
} }
}, },
{ {
@ -161,7 +182,9 @@ const categories = computed(() => [
endpoint: '/albums/', endpoint: '/albums/',
params: { params: {
contentCategory: 'podcast', contentCategory: 'podcast',
includeChannels: 'true' includeChannels: 'true',
page: 1,
page_size: 4
} }
}, },
{ {
@ -188,7 +211,7 @@ const categories = computed(() => [
post?: true post?: true
more?: string more?: string
params?: { params?: {
[key: string]: string [key: string]: string | number
} }
endpoint: `/${string}` endpoint: `/${string}`
}[]) }[])
@ -197,11 +220,9 @@ const categories = computed(() => [
// Show fetch if the query is a URL; show RSS if the query is an email address; show all other cateories otherwise // Show fetch if the query is a URL; show RSS if the query is an email address; show all other cateories otherwise
const availableCategories = computed(() => const availableCategories = computed(() =>
categories.value.filter(({ type }) => categories.value.filter(({ type }) =>
isFetch.value isFetch.value ? type === 'federation'
? type==='federation' : isRss.value ? type === 'rss'
: isRss.value : type !== 'federation' && type !== 'rss'
? type==='rss'
: type!=='federation' && type!=='rss'
)) ))
// Whenever available categories change, if there is exactly one, open it // Whenever available categories change, if there is exactly one, open it
@ -211,15 +232,28 @@ watch(availableCategories, () => {
}) })
/** /**
* Get the results for a given category * Get a list of the loaded results for a given category (max. 4)
* @param category The category to get the results for * @param category The category to get the results for
* @returns The results for the given category * @returns The results for the given category, in the form of an Array; `[]` if the category has not yet been queried
*/ */
const resultsPerCategory = <C extends Category>(category: { type: C }) => const resultsPerCategory = <C extends Category>(category: { type: C }) =>
results.value?.[category.type] || [] results.value[category.type] || []
/**
* Get the total number of results
* @param category The category to get the results for
* @returns The number of results for the given category according to the backend; `0` if the category has not yet been queried
*/
const count = <C extends Category>(category: { type: C }) => (
response => response && 'count' in response ? response.count : resultsPerCategory(category).length
) (responses.value[category.type])
/**
* Find out whether a category has been queried before
* @param category The category to which may have been queried
*/
const isCategoryQueried = <C extends Category>(category: { type: C }) => const isCategoryQueried = <C extends Category>(category: { type: C }) =>
results.value?.[category.type] ? true : false results.value[category.type] ? true : false
// Display // Display
@ -235,10 +269,10 @@ const openSections = ref<Set<Category>>(new Set())
* If no results are in currently expanded categories but some collapsed have results, show those * If no results are in currently expanded categories but some collapsed have results, show those
*/ */
watch(results, () => { watch(results, () => {
if (openCategories.value.some(category => resultsPerCategory(category).length > 0)) return if (openCategories.value.some(category => count(category) > 0)) return
const categoriesWithResults const categoriesWithResults
= availableCategories.value.filter(category => resultsPerCategory(category).length > 0) = availableCategories.value.filter(category => count(category) > 0)
if (categoriesWithResults.length === 0) return if (categoriesWithResults.length === 0) return
@ -257,8 +291,6 @@ const search = async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
q: queryDebounced.value, q: queryDebounced.value,
page: page.value.toString(),
page_size: paginateBy.value.toString(),
...(openCategories.value && 'params' in openCategories.value && openCategories.value.params ...(openCategories.value && 'params' in openCategories.value && openCategories.value.params
? openCategories.value.params ? openCategories.value.params
: {} : {}
@ -279,32 +311,61 @@ const search = async () => {
for (const category of categories) { for (const category of categories) {
try { try {
if (category.endpoint === '/search') { if (category.endpoint === '/search') {
console.log("SEARCHING BEGIN") const response = await axios.get<Response[typeof category.type]>(
const response = await axios.get<components['schemas']['SearchResult']>(
category.endpoint, category.endpoint,
{ params } { params }
) )
console.log("STORING BEGIN") // Store the four search results
console.log("Response:", response.data)
console.log("STORING BEGIN")
console.log(results.value)
results.value = { results.value = {
...results.value, ...results.value,
...response.data ...response.data
} }
console.log("STORING END") responses.value[category.type] = response.data
console.log(results.value)
} else { } else {
const response = await axios['post' in category ? 'post' : 'get']<Results[typeof category.type][0]>( if (category.type === 'rss') {
category.endpoint, const response = await axios.post<Response['rss']>(
isRss.value ? ({ url: trimmedQuery.value }) : ({ params }) category.endpoint,
) { url: trimmedQuery.value }
results.value = { )
...results.value, results.value.rss = [response.data]
[category.type]: response.data responses.value[category.type] = response.data
} else if (category.type === 'federation') {
const response = await axios.post<Response['federation']>(
category.endpoint,
{ params }
)
results.value.federation = [response.data]
responses.value[category.type] = response.data
} else if (category.type === 'playlists') {
const response = await axios.get<Response['playlists']>(
category.endpoint,
{ params }
)
results.value.playlists = response.data.results
responses.value[category.type] = response.data
} else if (category.type === 'podcasts') {
const response = await axios.get<Response['podcasts']>(
category.endpoint,
{ params }
)
results.value.podcasts = response.data.results
responses.value[category.type] = response.data
} else if (category.type === 'radios') {
const response = await axios.get<Response['radios']>(
category.endpoint,
{ params }
)
results.value.radios = response.data.results
responses.value[category.type] = response.data
} else if (category.type === 'series') {
const response = await axios.get<Response['series']>(
category.endpoint,
{ params }
)
results.value.series = response.data.results
responses.value[category.type] = response.data
} }
} }
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
} }
@ -316,17 +377,18 @@ const search = async () => {
// Configure the radio // Configure the radio
const radioConfig = computed<RadioConfig | null>(() => const radioConfig = computed<RadioConfig | null>(() =>
resultsPerCategory({ type: 'tags' }).length > 0 count({ type: 'tags' }) > 0
? ({ ? ({
type: 'tag', type: 'tag',
names: resultsPerCategory({ type: 'tags' }).map(({ name }) => name) names: resultsPerCategory({ type: 'tags' })
.map((({ name }) => name))
}) })
: resultsPerCategory({ type: 'playlists' }).length > 0 : count({ type: 'playlists' }) > 0
? ({ ? ({
type: 'playlist', type: 'playlist',
ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString()) ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString())
}) })
: resultsPerCategory({ type: 'artists' }).length > 0 : count({ type: 'artists' }) > 0
? ({ ? ({
type: 'artist', type: 'artist',
ids: resultsPerCategory({ type: 'artists' }).map(({ id }) => id.toString()) ids: resultsPerCategory({ type: 'artists' }).map(({ id }) => id.toString())
@ -343,6 +405,7 @@ watch(queryDebounced, search, { immediate: true })
<Modal <Modal
v-model="isOpen" v-model="isOpen"
over-popover over-popover
autofocus="off"
title="" title=""
> >
<template #topleft> <template #topleft>
@ -352,13 +415,6 @@ watch(queryDebounced, search, { immediate: true })
:autofocus="openCategories.length===0" :autofocus="openCategories.length===0"
icon="bi-search" icon="bi-search"
/> />
<!-- <Button
v-bind="{[results? 'primary' : 'disabled']: true}"
min-width
@click="startRadio"
>
{{ t('components.audio.podcast.Modal.button.startRadio') }}
</Button> -->
<RadioButton <RadioButton
v-if="radioConfig" v-if="radioConfig"
class="ui right floated medium button" class="ui right floated medium button"
@ -374,7 +430,7 @@ watch(queryDebounced, search, { immediate: true })
<template <template
v-for="category in availableCategories" v-for="category in availableCategories"
:key="category.type" :key="category.type + isCategoryQueried(category)"
> >
<Section <Section
align-left align-left
@ -382,30 +438,16 @@ watch(queryDebounced, search, { immediate: true })
:h3="`${ :h3="`${
!isCategoryQueried(category) !isCategoryQueried(category)
? '...' ? '...'
: resultsPerCategory(category).length > 0 : count(category) > 0
? `${resultsPerCategory(category).length} ` ? `${count(category)} `
: '' : ''
}${category.label}`" }${category.label}`"
:v-bind=" v-bind="
openSections.has(category.type) openSections.has(category.type)
? ({ collapse: () => openSections.delete(category.type) }) ? ({ collapse: () => openSections.delete(category.type) })
: ({ expand: () => openSections.add(category.type) }) : ({ expand: () => openSections.add(category.type) })
" "
> >
<EmptyState
v-if="resultsPerCategory(category).length === 0"
style="grid-column: 1 / -1"
:refresh="true"
@refresh="search"
/>
<Link
v-else-if="'more' in category"
secondary
style="grid-column: -1"
:to="category.more"
>
{{ t('components.Home.link.viewMore') }}
</Link>
<!-- Categories that have one list-style item --> <!-- Categories that have one list-style item -->
@ -418,7 +460,7 @@ watch(queryDebounced, search, { immediate: true })
v-else-if="category.type === 'tags'" v-else-if="category.type === 'tags'"
style="grid-column: 1 / -1" style="grid-column: 1 / -1"
:truncate-size="200" :truncate-size="200"
:limit="paginateBy" :limit="category.params.page_size"
:tags="(resultsPerCategory(category)).map(t => t.name)" :tags="(resultsPerCategory(category)).map(t => t.name)"
/> />
@ -432,12 +474,12 @@ watch(queryDebounced, search, { immediate: true })
:artist="resultsPerCategory(category)[index]" :artist="resultsPerCategory(category)[index]"
/> />
<!-- <AlbumCard <AlbumCard
v-else-if="category.type === 'albums' || category.type === 'series'" v-else-if="category.type === 'albums' || category.type === 'series'"
:album="resultsPerCategory(category)[index]" :album="resultsPerCategory(category)[index]"
/> --> />
<!-- <PlaylistCard <PlaylistCard
v-else-if="category.type === 'playlists'" v-else-if="category.type === 'playlists'"
:playlist="resultsPerCategory(category)[index]" :playlist="resultsPerCategory(category)[index]"
/> />
@ -446,12 +488,12 @@ watch(queryDebounced, search, { immediate: true })
v-else-if="category.type === 'radios'" v-else-if="category.type === 'radios'"
type="custom" type="custom"
:custom-radio="resultsPerCategory(category)[index]" :custom-radio="resultsPerCategory(category)[index]"
/> --> />
</template> </template>
<!-- If response has "url": "webfinger://node1@node1.funkwhale.test" -> Link to go directly to the federation page --> <!-- If response has "url": "webfinger://node1@node1.funkwhale.test" -> Link to go directly to the federation page -->
<span v-if="category.type === 'rss' && resultsPerCategory(category).length > 0"> <span v-if="category.type === 'rss' && count(category) > 0">
<Alert>If the following link does not work, wait a few seconds and try again</Alert> <Alert>If the following link does not work, wait a few seconds and try again</Alert>
<Link <Link
v-for="channel in resultsPerCategory(category)" v-for="channel in resultsPerCategory(category)"
@ -466,6 +508,21 @@ watch(queryDebounced, search, { immediate: true })
<span v-else-if="category.type === 'federation'"> <span v-else-if="category.type === 'federation'">
TODO: {{ resultsPerCategory(category) }} TODO: {{ resultsPerCategory(category) }}
</span> </span>
<EmptyState
v-if="count(category) === 0"
style="grid-column: 1 / -1"
:refresh="true"
@refresh="search"
/>
<Link
v-else-if="'more' in category"
ghost
full
:to="category.more"
>
{{ t('components.Home.link.viewMore') }}
</Link>
</Section> </Section>
</template> </template>
</Modal> </Modal>

View File

@ -28,6 +28,10 @@ const general = computed(() => [
key: 'shift + f', key: 'shift + f',
summary: t('components.ShortcutsModal.shortcut.general.focus') summary: t('components.ShortcutsModal.shortcut.general.focus')
}, },
{
key: '/',
summary: t('components.ShortcutsModal.shortcut.general.focus')
},
{ {
key: 'esc', key: 'esc',
summary: t('components.ShortcutsModal.shortcut.general.unfocus') summary: t('components.ShortcutsModal.shortcut.general.unfocus')
@ -134,7 +138,7 @@ const player = computed(() => [
>{{ shortcut.summary }}</span> >{{ shortcut.summary }}</span>
<Spacer grow /> <Spacer grow />
<Button <Button
style="pointer-events:none;" style="pointer-events: none;"
min-content min-content
> >
{{ shortcut.key }} {{ shortcut.key }}