feat(front): [WIP] improve search modal

This commit is contained in:
upsiflu 2025-03-20 21:35:36 +01:00
parent c42f08babe
commit f4fee5dc8c
1 changed files with 190 additions and 124 deletions

View File

@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { type components, type paths } from '~/generated/types.ts' import type { paths, components } from '~/generated/types.ts'
import type { RadioConfig } from '~/store/radios'
import axios from 'axios' import axios from 'axios'
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { refDebounced } from '@vueuse/core' import { refDebounced } from '@vueuse/core'
import { trim } from 'lodash-es' import { trim, uniqBy } from 'lodash-es'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -14,6 +15,7 @@ import PlaylistCard from '~/components/playlists/Card.vue'
import TrackTable from '~/components/audio/track/Table.vue' import TrackTable from '~/components/audio/track/Table.vue'
import AlbumCard from '~/components/album/Card.vue' import AlbumCard from '~/components/album/Card.vue'
import RadioCard from '~/components/radios/Card.vue' import RadioCard from '~/components/radios/Card.vue'
import RadioButton from '~/components/radios/Button.vue'
import TagsList from '~/components/tags/List.vue' import TagsList from '~/components/tags/List.vue'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
@ -28,20 +30,42 @@ import EmptyState from '~/components/common/EmptyState.vue'
const { t } = useI18n() const { t } = useI18n()
const { isOpen, value: query } = useModal('search', { on: () => '', isOn: (value) => value !== undefined && value !== '' }) const { isOpen, value: query } = useModal(
'search', {
on: () => '',
isOn: (value) => value !== undefined && value !== ''
})
const startRadio = () => { const startRadio = () => {
// TODO: Start the radio // TODO: Start the radio
} }
// Pagination // TODO:
// - Limit search results to 4
// - Add Link to specific search pages in each section where it applies
// - Read out the count from `result` (instead of the max. 4 visible results)
// Note: In funkwhale v1, the page is stored as a parameter in the Url. This may make sense if we get 100+ search results. /*
// I wonder if anyone would comb through 100 results though instead of fine-tuning the filter to condense the list, but there may be a case where it's useful.
// -> TODO: Implement pagination in the search results - Categories (an Array of all categories) <- static configuration
|
| filter according to search query
v
- Available Categories (also an Array of category configs)
|
| make sure that `open sections` is a
| subset of available category types
|
* Open Sections (a Set of category types) <- user can expand/collapse sections
| <- new results can also open a category:
| if all were closed or none had results
v
- Open Categories (this value is just computed, based on open sections)
*/
const page = ref<number>(1) const page = ref<number>(1)
const paginateBy = ref<number>(100) const paginateBy = ref<number>(4)
// Search // Search
@ -57,16 +81,23 @@ 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 Results = {
artists: components['schemas']['SearchResult']['artists'], artists: paths['/api/v2/search']['get']['responses']['200']['content']['application/json']['artists'],
albums: components['schemas']['SearchResult']['albums'], albums: paths['/api/v2/search']['get']['responses']['200']['content']['application/json']['albums'],
tracks: components['schemas']['SearchResult']['tracks'], tracks: paths['/api/v2/search']['get']['responses']['200']['content']['application/json']['tracks'],
tags: components['schemas']['SearchResult']['tags'], tags: paths['/api/v2/search']['get']['responses']['200']['content']['application/json']['tags'],
playlists: components['schemas']['Playlist'][], playlists: Response['playlists']['results'],
radios: components['schemas']['Radio'][], radios: Response['radios']['results'],
podcasts: components['schemas']['Artist'][], podcasts: Response['podcasts']['results'],
series: components['schemas']['Album'][], series: Response['series']['results'],
rss: components['schemas']['Channel'][], rss: paths['/api/v2/channels/rss-subscribe/']['post']['responses']['200']['content']['application/json'],
federation: components['schemas']['Fetch'][] federation: paths['/api/v2/federation/fetches/']['post']['responses']['201']['content']['application/json']
}
type Response = {
playlists: paths['/api/v2/playlists/']['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'],
series: paths['/api/v2/albums/']['get']['responses']['200']['content']['application/json'],
} }
const results = ref<Partial<Results>>() const results = ref<Partial<Results>>()
@ -75,6 +106,7 @@ const categories = computed(() => [
{ {
type: 'artists', type: 'artists',
label: t('views.Search.label.artists'), label: t('views.Search.label.artists'),
more: '/library/artists',
endpoint: '/search', endpoint: '/search',
params: { params: {
contentCategory: 'music', contentCategory: 'music',
@ -84,6 +116,7 @@ const categories = computed(() => [
{ {
type: 'albums', type: 'albums',
label: t('views.Search.label.albums'), label: t('views.Search.label.albums'),
more: '/library/albums',
endpoint: '/search', endpoint: '/search',
params: { params: {
includeChannels: 'true', includeChannels: 'true',
@ -103,17 +136,20 @@ const categories = computed(() => [
{ {
type: 'playlists', type: 'playlists',
label: t('views.Search.label.playlists'), label: t('views.Search.label.playlists'),
more: '/library/playlists/',
endpoint: '/TODO' endpoint: '/TODO'
}, },
{ {
type: 'radios', type: 'radios',
label: t('views.Search.label.radios'), label: t('views.Search.label.radios'),
endpoint: '/radios/radios' more: '/library/radios',
endpoint: '/radios/radios/'
}, },
{ {
type: 'podcasts', type: 'podcasts',
label: t('views.Search.label.podcasts'), label: t('views.Search.label.podcasts'),
endpoint: '/artists', more: '/library/podcasts',
endpoint: '/artists/',
params: { params: {
contentCategory: 'podcast', contentCategory: 'podcast',
includeChannels: 'true' includeChannels: 'true'
@ -122,7 +158,7 @@ const categories = computed(() => [
{ {
type: 'series', type: 'series',
label: t('views.Search.label.series'), label: t('views.Search.label.series'),
endpoint: '/albums', endpoint: '/albums/',
params: { params: {
contentCategory: 'podcast', contentCategory: 'podcast',
includeChannels: 'true' includeChannels: 'true'
@ -150,6 +186,7 @@ const categories = computed(() => [
type: Category type: Category
label: string label: string
post?: true post?: true
more?: string
params?: { params?: {
[key: string]: string [key: string]: string
} }
@ -164,14 +201,13 @@ const availableCategories = computed(() =>
? type==='federation' ? type==='federation'
: isRss.value : isRss.value
? type==='rss' ? type==='rss'
: !['federation', 'rss'].includes(type) : type!=='federation' && type!=='rss'
)) ))
// If there is only one available category, open it immediately // Whenever available categories change, if there is exactly one, open it
watch(availableCategories, () => { watch(availableCategories, () => {
if (availableCategories.value.length === 1 && openSectionHistory.value.at(0) !== availableCategories.value[0].type) { if (availableCategories.value.length === 1)
openSectionHistory.value.unshift(availableCategories.value[0].type) openSections.value = new Set(availableCategories.value.map(category => category.type))
}
}) })
/** /**
@ -179,49 +215,37 @@ watch(availableCategories, () => {
* @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
*/ */
const resultsPerCategory = (category: Category) => const resultsPerCategory = <C extends Category>(category: { type: C }) =>
results.value?.[category] || [] results.value?.[category.type] || []
const isCategoryQueried = (category: Category) => const isCategoryQueried = <C extends Category>(category: { type: C }) =>
results.value?.[category] ? true : false results.value?.[category.type] ? true : false
// Display // Display
// At most one section is open ('current') at a given moment. It determines which category is 'current'.
const currentCategory = computed(() => categories.value.find(({ type }) => type === openSectionHistory.value.at(0))) const openCategories = computed(() =>
categories.value.filter(({ type }) => openSections.value.has(type))
)
// Parse the category to match a field in the SearchResults type // Sections can be manually or automatically toggled
type SearchResultCategories = keyof components['schemas']['SearchResult']
const searchResultCategories: SearchResultCategories[] = ['artists', 'albums', 'tracks', 'tags'] satisfies Category[]
const parseCategoryExpected = (category: Category | undefined): SearchResultCategories | undefined =>
searchResultCategories.find(expected => category === expected)
const openSections = ref<Set<Category>>(new Set())
/**
* If no results are in currently expanded categories but some collapsed have results, show those
*/
watch(results, () => { watch(results, () => {
// Currently open section has some items? if (openCategories.value.some(category => resultsPerCategory(category).length > 0)) return
if ((currentCategory.value && results.value?.[currentCategory.value.type]?.length) || 0 > 0)
return
// Any other section has some items? const categoriesWithResults
const firstCategoryWithResults = categories.value.find(category => = availableCategories.value.filter(category => resultsPerCategory(category).length > 0)
(results.value?.[category.type]?.length || 0) > 0
)
// Then open it! if (categoriesWithResults.length === 0) return
if (firstCategoryWithResults)
openSectionHistory.value.unshift(firstCategoryWithResults.type) openSections.value = new Set(categoriesWithResults.map(({ type }) => type))
}) })
// Showing one section at a time (Accordion behaviour; clicking an open section navigates to the previous section) // Search
const openSectionHistory = ref<Category[]>([])
const toggleSection = (id: Category): void => {
if (id === openSectionHistory.value.at(0)) {
openSectionHistory.value.shift()
} else {
openSectionHistory.value.unshift(id)
}
}
const search = async () => { const search = async () => {
@ -231,57 +255,87 @@ const search = async () => {
return return
} }
// If query has the shape of an Uri, search the federation
// Else, use the user database endpoint to search
const params = new URLSearchParams({ const params = new URLSearchParams({
q: queryDebounced.value, q: queryDebounced.value,
page: page.value.toString(), page: page.value.toString(),
page_size: paginateBy.value.toString(), page_size: paginateBy.value.toString(),
...(currentCategory.value && 'params' in currentCategory.value && currentCategory.value.params ...(openCategories.value && 'params' in openCategories.value && openCategories.value.params
? currentCategory.value.params ? openCategories.value.params
: {} : {}
) )
}) })
// Query either the user-secelcted category or the first one that is compatible with the query // Only query category that are open / available. Omit duplicate queries (`uniqBy`).
const category = currentCategory.value || availableCategories.value[0] const categories
= uniqBy(
openCategories.value.length > 0
? openCategories.value
: availableCategories.value,
(category => category.endpoint + ('params' in category && JSON.stringify(category.params)))
)
isLoading.value = true isLoading.value = true
try { for (const category of categories) {
const searchResultCategory = parseCategoryExpected(category.type) try {
if (category.endpoint === '/search') {
if (searchResultCategory) { console.log("SEARCHING BEGIN")
const response = await axios.get<components['schemas']['SearchResult']>(
const response = await axios.get<components['schemas']['SearchResult']>( category.endpoint,
category.endpoint, { params }
{ params } )
) console.log("STORING BEGIN")
results.value = { console.log("Response:", response.data)
...results.value, console.log("STORING BEGIN")
...response.data console.log(results.value)
} results.value = {
} else { ...results.value,
console.log("endpoint", category.endpoint) ...response.data
console.log("params", params) }
const response = await axios['post' in category ? 'post' : 'get']<Results[typeof category.type][0]>( console.log("STORING END")
console.log(results.value)
} else {
const response = await axios['post' in category ? 'post' : 'get']<Results[typeof category.type][0]>(
category.endpoint, category.endpoint,
isRss.value ? ({ url: trimmedQuery.value }) : ({ params }) isRss.value ? ({ url: trimmedQuery.value }) : ({ params })
) )
results.value = { results.value = {
...results.value, ...results.value,
[category.type]: response.data [category.type]: response.data
}
} }
} catch (error) {
useErrorHandler(error as Error)
} }
} catch (error) {
useErrorHandler(error as Error)
} }
isLoading.value = false isLoading.value = false
} }
// Configure the radio
const radioConfig = computed<RadioConfig | null>(() =>
resultsPerCategory({ type: 'tags' }).length > 0
? ({
type: 'tag',
names: resultsPerCategory({ type: 'tags' }).map(({ name }) => name)
})
: resultsPerCategory({ type: 'playlists' }).length > 0
? ({
type: 'playlist',
ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString())
})
: resultsPerCategory({ type: 'artists' }).length > 0
? ({
type: 'artist',
ids: resultsPerCategory({ type: 'artists' }).map(({ id }) => id.toString())
})
: null
)
// Start the search
watch(queryDebounced, search, { immediate: true }) watch(queryDebounced, search, { immediate: true })
</script> </script>
@ -294,17 +348,23 @@ watch(queryDebounced, search, { immediate: true })
<template #topleft> <template #topleft>
<Input <Input
v-model="query" v-model="query"
ghost raised
autofocus :autofocus="openCategories.length===0"
icon="bi-search" icon="bi-search"
/> />
<Button <!-- <Button
v-bind="{[results? 'primary' : 'disabled']: true}" v-bind="{[results? 'primary' : 'disabled']: true}"
min-width min-width
@click="startRadio" @click="startRadio"
> >
{{ t('components.audio.podcast.Modal.button.startRadio') }} {{ t('components.audio.podcast.Modal.button.startRadio') }}
</Button> </Button> -->
<RadioButton
v-if="radioConfig"
class="ui right floated medium button"
type="custom_multiple"
:radio-config="radioConfig"
/>
</template> </template>
<Spacer /> <Spacer />
@ -316,80 +376,86 @@ watch(queryDebounced, search, { immediate: true })
v-for="category in availableCategories" v-for="category in availableCategories"
:key="category.type" :key="category.type"
> >
<!-- Each section collapses if it is not current -->
<Section <Section
align-left align-left
:action="{
text: `${
!isCategoryQueried(category.type)
? '...'
: resultsPerCategory(category.type).length > 0
? `${resultsPerCategory(category.type).length} `
: ''
}${category.label}`,
onClick: () => {
toggleSection(category.type)
}
}"
no-items no-items
:collapsed="currentCategory?.type !== category.type" :h3="`${
!isCategoryQueried(category)
? '...'
: resultsPerCategory(category).length > 0
? `${resultsPerCategory(category).length} `
: ''
}${category.label}`"
:v-bind="
openSections.has(category.type)
? ({ collapse: () => openSections.delete(category.type) })
: ({ expand: () => openSections.add(category.type) })
"
> >
<EmptyState <EmptyState
v-if="resultsPerCategory(category.type).length === 0" v-if="resultsPerCategory(category).length === 0"
style="grid-column: 1 / -1" style="grid-column: 1 / -1"
:refresh="true" :refresh="true"
@refresh="search" @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 -->
<TrackTable <TrackTable
v-if="category.type === 'tracks'" v-if="category.type === 'tracks'"
style="grid-column: 1 / -1" style="grid-column: 1 / -1"
:tracks="resultsPerCategory('tracks') as components['schemas']['SearchResult']['tracks']" :tracks="resultsPerCategory(category)"
/> />
<TagsList <TagsList
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="paginateBy"
:tags="(resultsPerCategory('tags') as components['schemas']['SearchResult']['tags']).map(t => t.name)" :tags="(resultsPerCategory(category)).map(t => t.name)"
/> />
<!-- Categories that show individual cards --> <!-- Categories that show individual cards -->
<!-- If the category is expected to yield results, display the results in Cards or Activities -->
<template <template
v-for="(result, index) in (resultsPerCategory(category.type))" v-for="(_, index) in (resultsPerCategory(category))"
:key="category.type + index" :key="category.type + index"
> >
<ArtistCard <ArtistCard
v-if="category.type === 'artists' || category.type === 'podcasts'" v-if="(category.type === 'artists' || category.type === 'podcasts')"
:artist="result" :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="result as components['schemas']['SearchResult']['albums'][0]" :album="resultsPerCategory(category)[index]"
/> /> -->
<PlaylistCard <!-- <PlaylistCard
v-else-if="category.type === 'playlists'" v-else-if="category.type === 'playlists'"
:playlist="result as components['schemas']['Playlist']" :playlist="resultsPerCategory(category)[index]"
/> />
<RadioCard <RadioCard
v-else-if="category.type === 'radios'" v-else-if="category.type === 'radios'"
type="custom" type="custom"
:custom-radio="result as components['schemas']['Radio']" :custom-radio="resultsPerCategory(category)[index]"
/> /> -->
</template> </template>
<!-- TODO: Implement results from non-`/v2/search` requests (federated/uri results etc.) here -->
<!-- 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'"> <span v-if="category.type === 'rss' && resultsPerCategory(category).length > 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)"
:key="channel.artist.fid"
:to="channel.artist.fid" :to="channel.artist.fid"
autofocus autofocus
> >
@ -398,7 +464,7 @@ watch(queryDebounced, search, { immediate: true })
</span> </span>
<span v-else-if="category.type === 'federation'"> <span v-else-if="category.type === 'federation'">
{{ resultsPerCategory(category.type) }} TODO: {{ resultsPerCategory(category) }}
</span> </span>
</Section> </Section>
</template> </template>