Feat(front): implement new search process
Co-Authored-By: ArneBo <arne@ecobasa.org> Co-Authored-By: Flupsi <upsiflu@gmail.com> Co-Authored-By: jon r <jon@allmende.io>
This commit is contained in:
parent
24fb0cf9ec
commit
20e23d8da9
|
@ -0,0 +1,528 @@
|
|||
<script setup lang="ts">
|
||||
import type { paths } from '~/generated/types.ts'
|
||||
import type { RadioConfig } from '~/store/radios'
|
||||
import axios from 'axios'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { trim, uniqBy } from 'lodash-es'
|
||||
|
||||
import useErrorHandler from '~/composables/useErrorHandler'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import ArtistCard from '~/components/artist/Card.vue'
|
||||
import PlaylistCard from '~/components/playlists/Card.vue'
|
||||
import TrackTable from '~/components/audio/track/Table.vue'
|
||||
import AlbumCard from '~/components/album/Card.vue'
|
||||
import RadioCard from '~/components/radios/Card.vue'
|
||||
import RadioButton from '~/components/radios/Button.vue'
|
||||
import TagsList from '~/components/tags/List.vue'
|
||||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Section from '~/components/ui/Section.vue'
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
import Alert from '~/components/ui/Alert.vue'
|
||||
import EmptyState from '~/components/common/EmptyState.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isOpen, value: query } = useModal(
|
||||
'search', {
|
||||
on: () => '',
|
||||
isOn: (value) => value !== undefined && value !== ''
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
/*
|
||||
|
||||
- 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)
|
||||
|
||||
*/
|
||||
|
||||
// Search query
|
||||
|
||||
const queryDebounced = refDebounced(query, 500)
|
||||
const trimmedQuery = computed(() => trim(trim(queryDebounced.value), '@'))
|
||||
const isFetch = computed(() => ((trimmedQuery.value.startsWith('http://') || trimmedQuery.value.startsWith('https://')) || trimmedQuery.value.includes('@')) && !isRss.value)
|
||||
const isRss = computed(() => trimmedQuery.value.includes('.rss') || trimmedQuery.value.includes('.xml'))
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Filter
|
||||
|
||||
type Category = 'artists' | 'albums' | 'tracks' | 'playlists' | 'tags' | 'radios' | 'podcasts' | 'series' | 'rss' | 'federation'
|
||||
|
||||
type SearchResponse = paths['/api/v2/search']['get']['responses']['200']['content']['application/json']
|
||||
|
||||
type Response = {
|
||||
artists: SearchResponse,
|
||||
albums: SearchResponse,
|
||||
tracks: SearchResponse,
|
||||
tags: SearchResponse,
|
||||
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'],
|
||||
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']
|
||||
}
|
||||
|
||||
/** 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(() => [
|
||||
{
|
||||
type: 'artists',
|
||||
label: t('views.Search.label.artists'),
|
||||
more: '/library/artists',
|
||||
endpoint: '/search',
|
||||
params: {
|
||||
contentCategory: 'music',
|
||||
includeChannels: 'true',
|
||||
page: 1,
|
||||
page_size: 4
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'albums',
|
||||
label: t('views.Search.label.albums'),
|
||||
more: '/library/albums',
|
||||
endpoint: '/search',
|
||||
params: {
|
||||
contentCategory: 'music',
|
||||
includeChannels: 'true',
|
||||
page: 1,
|
||||
page_size: 4
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'tracks',
|
||||
label: t('views.Search.label.tracks'),
|
||||
endpoint: '/search',
|
||||
params: {
|
||||
page: 1,
|
||||
page_size: 24
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'tags',
|
||||
label: t('views.Search.label.tags'),
|
||||
endpoint: '/search',
|
||||
params: {
|
||||
page: 1,
|
||||
page_size: 24
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'playlists',
|
||||
label: t('views.Search.label.playlists'),
|
||||
more: '/library/playlists/',
|
||||
endpoint: '/TODO'
|
||||
},
|
||||
{
|
||||
type: 'radios',
|
||||
label: t('views.Search.label.radios'),
|
||||
more: '/library/radios',
|
||||
endpoint: '/radios/radios/',
|
||||
params: {
|
||||
page: 1,
|
||||
page_size: 4
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'podcasts',
|
||||
label: t('views.Search.label.podcasts'),
|
||||
more: '/library/podcasts',
|
||||
endpoint: '/artists/',
|
||||
params: {
|
||||
contentCategory: 'podcast',
|
||||
includeChannels: 'true',
|
||||
page: 1,
|
||||
page_size: 4
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'series',
|
||||
label: t('views.Search.label.series'),
|
||||
endpoint: '/albums/',
|
||||
params: {
|
||||
contentCategory: 'podcast',
|
||||
includeChannels: 'true',
|
||||
page: 1,
|
||||
page_size: 4
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'rss',
|
||||
label: t('views.Search.header.rss'),
|
||||
endpoint: '/channels/rss-subscribe/',
|
||||
post: true,
|
||||
params: {
|
||||
url: trimmedQuery.value
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'federation',
|
||||
label: t('views.Search.header.remote'),
|
||||
endpoint: '/federation/fetches/',
|
||||
post: true,
|
||||
params: {
|
||||
object: trimmedQuery.value
|
||||
}
|
||||
}
|
||||
] as const satisfies {
|
||||
type: Category
|
||||
label: string
|
||||
post?: true
|
||||
more?: string
|
||||
params?: {
|
||||
[key: string]: string | number
|
||||
}
|
||||
endpoint: `/${string}`
|
||||
}[])
|
||||
|
||||
// Limit the available categories based on the search query
|
||||
// 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(() =>
|
||||
categories.value.filter(({ type }) =>
|
||||
isFetch.value ? type === 'federation'
|
||||
: isRss.value ? type === 'rss'
|
||||
: type !== 'federation' && type !== 'rss'
|
||||
))
|
||||
|
||||
// Whenever available categories change, if there is exactly one, open it
|
||||
watch(availableCategories, () => {
|
||||
if (availableCategories.value.length === 1)
|
||||
openSections.value = new Set(availableCategories.value.map(category => category.type))
|
||||
})
|
||||
|
||||
/**
|
||||
* Get a list of the loaded results for a given category (max. 4)
|
||||
* @param category The category to get the results for
|
||||
* @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 }) =>
|
||||
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 }) =>
|
||||
results.value[category.type] ? true : false
|
||||
|
||||
// Display
|
||||
|
||||
const openCategories = computed(() =>
|
||||
categories.value.filter(({ type }) => openSections.value.has(type))
|
||||
)
|
||||
|
||||
// Sections can be manually or automatically toggled
|
||||
|
||||
const openSections = ref<Set<Category>>(new Set())
|
||||
|
||||
/**
|
||||
* If no results are in currently expanded categories but some collapsed have results, show those
|
||||
*/
|
||||
watch(results, () => {
|
||||
if (openCategories.value.some(category => count(category) > 0)) return
|
||||
|
||||
const categoriesWithResults
|
||||
= availableCategories.value.filter(category => count(category) > 0)
|
||||
|
||||
if (categoriesWithResults.length === 0) return
|
||||
|
||||
openSections.value = new Set(categoriesWithResults.map(({ type }) => type))
|
||||
})
|
||||
|
||||
// Search
|
||||
|
||||
const search = async () => {
|
||||
|
||||
// Close if query is empty
|
||||
if (trimmedQuery.value.length < 1) {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: queryDebounced.value,
|
||||
...(openCategories.value && 'params' in openCategories.value && openCategories.value.params
|
||||
? openCategories.value.params
|
||||
: {}
|
||||
)
|
||||
})
|
||||
|
||||
// Only query category that are open / available. Omit duplicate queries (`uniqBy`).
|
||||
const categories
|
||||
= uniqBy(
|
||||
openCategories.value.length > 0
|
||||
? openCategories.value
|
||||
: availableCategories.value,
|
||||
(category => category.endpoint + ('params' in category && JSON.stringify(category.params)))
|
||||
)
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
for (const category of categories) {
|
||||
try {
|
||||
if (category.endpoint === '/search') {
|
||||
const response = await axios.get<Response[typeof category.type]>(
|
||||
category.endpoint,
|
||||
{ params }
|
||||
)
|
||||
// Store the four search results
|
||||
results.value = {
|
||||
...results.value,
|
||||
...response.data
|
||||
}
|
||||
responses.value[category.type] = response.data
|
||||
} else {
|
||||
if (category.type === 'rss') {
|
||||
const response = await axios.post<Response['rss']>(
|
||||
category.endpoint,
|
||||
{ url: trimmedQuery.value }
|
||||
)
|
||||
results.value.rss = [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) {
|
||||
useErrorHandler(error as Error)
|
||||
}
|
||||
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// Configure the radio
|
||||
|
||||
const radioConfig = computed<RadioConfig | null>(() =>
|
||||
count({ type: 'tags' }) > 0
|
||||
? ({
|
||||
type: 'tag',
|
||||
names: resultsPerCategory({ type: 'tags' })
|
||||
.map((({ name }) => name))
|
||||
})
|
||||
: count({ type: 'playlists' }) > 0
|
||||
? ({
|
||||
type: 'playlist',
|
||||
ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString())
|
||||
})
|
||||
: count({ type: 'artists' }) > 0
|
||||
? ({
|
||||
type: 'artist',
|
||||
ids: resultsPerCategory({ type: 'artists' }).map(({ id }) => id.toString())
|
||||
})
|
||||
: null
|
||||
)
|
||||
|
||||
// Start the search
|
||||
|
||||
watch(queryDebounced, search, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model="isOpen"
|
||||
over-popover
|
||||
autofocus="off"
|
||||
title=""
|
||||
>
|
||||
<template #topleft>
|
||||
<Input
|
||||
v-model="query"
|
||||
raised
|
||||
:autofocus="openCategories.length===0"
|
||||
icon="bi-search"
|
||||
/>
|
||||
<RadioButton
|
||||
v-if="radioConfig"
|
||||
class="ui right floated medium button"
|
||||
type="custom_multiple"
|
||||
:radio-config="radioConfig"
|
||||
/>
|
||||
</template>
|
||||
<Spacer />
|
||||
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
/>
|
||||
|
||||
<template
|
||||
v-for="category in availableCategories"
|
||||
:key="category.type + isCategoryQueried(category)"
|
||||
>
|
||||
<Section
|
||||
align-left
|
||||
:columns-per-item="1"
|
||||
:h3="`${
|
||||
!isCategoryQueried(category)
|
||||
? '...'
|
||||
: count(category) > 0
|
||||
? `${count(category)} `
|
||||
: ''
|
||||
}${category.label}`"
|
||||
v-bind="
|
||||
openSections.has(category.type)
|
||||
? ({ collapse: () => openSections.delete(category.type) })
|
||||
: ({ expand: () => openSections.add(category.type) })
|
||||
"
|
||||
>
|
||||
<!-- Categories that have one list-style item -->
|
||||
|
||||
<TrackTable
|
||||
v-if="category.type === 'tracks'"
|
||||
style="grid-column: 1 / -1"
|
||||
:tracks="resultsPerCategory(category)"
|
||||
/>
|
||||
<TagsList
|
||||
v-else-if="category.type === 'tags'"
|
||||
style="grid-column: 1 / -1"
|
||||
:truncate-size="200"
|
||||
:limit="category.params.page_size"
|
||||
:tags="(resultsPerCategory(category)).map(t => t.name)"
|
||||
/>
|
||||
|
||||
<!-- Categories that show individual cards -->
|
||||
<template
|
||||
v-for="(_, index) in (resultsPerCategory(category))"
|
||||
:key="category.type + index"
|
||||
>
|
||||
<ArtistCard
|
||||
v-if="(category.type === 'artists' || category.type === 'podcasts')"
|
||||
:artist="resultsPerCategory(category)[index]"
|
||||
/>
|
||||
|
||||
<AlbumCard
|
||||
v-else-if="category.type === 'albums' || category.type === 'series'"
|
||||
:album="resultsPerCategory(category)[index]"
|
||||
/>
|
||||
|
||||
<PlaylistCard
|
||||
v-else-if="category.type === 'playlists'"
|
||||
:playlist="resultsPerCategory(category)[index]"
|
||||
/>
|
||||
|
||||
<RadioCard
|
||||
v-else-if="category.type === 'radios'"
|
||||
type="custom"
|
||||
:custom-radio="resultsPerCategory(category)[index]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- If response has "url": "webfinger://node1@node1.funkwhale.test" -> Link to go directly to the federation page -->
|
||||
|
||||
<span v-if="category.type === 'rss' && count(category) > 0">
|
||||
<Alert>{{ t('modals.search.tryAgain') }}</Alert>
|
||||
<Link
|
||||
v-for="channel in resultsPerCategory(category)"
|
||||
:key="channel.artist.fid"
|
||||
:to="channel.artist.fid"
|
||||
autofocus
|
||||
>
|
||||
{{ channel.artist.name }}
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
<span v-else-if="category.type === 'federation'">
|
||||
<!-- TODO: Federation search: backend adapter + display, fix results_per_category query -->
|
||||
<!-- {{ resultsPerCategory(category) }} -->
|
||||
</span>
|
||||
|
||||
<EmptyState
|
||||
v-if="count(category) === 0"
|
||||
style="grid-column: 1 / -1"
|
||||
:refresh="true"
|
||||
@refresh="search"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="'more' in category"
|
||||
solid
|
||||
secondary
|
||||
:to="category.more"
|
||||
>
|
||||
{{ t('components.Home.link.viewMore') }}
|
||||
</Link>
|
||||
</Section>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
Loading…
Reference in New Issue