feat(front): #2421 search modal uses different endpoints depending on query and user-chosen category (section)
This commit is contained in:
parent
806f49e061
commit
58341b107b
|
@ -37,6 +37,11 @@ const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index'
|
|||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// Search bar focus
|
||||
|
||||
const search = ref<HTMLInputElement>()
|
||||
onKeyboardShortcut(['shift', 'f'], () => search.value?.focus(), true)
|
||||
onKeyboardShortcut(['ctrl', 'k'], () => search.value?.focus(), true)
|
||||
|
||||
// Admin notifications
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import { type components } from '~/generated/types.ts'
|
|||
import axios from 'axios'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { trim } from 'lodash-es'
|
||||
|
||||
import useErrorHandler from '~/composables/useErrorHandler'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
@ -10,7 +11,6 @@ import { useModal } from '~/ui/composables/useModal.ts'
|
|||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Section from '~/components/ui/Section.vue'
|
||||
|
@ -24,136 +24,155 @@ const startRadio = () => {
|
|||
// TODO: Start the radio
|
||||
}
|
||||
|
||||
// Pagination
|
||||
|
||||
// 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
|
||||
|
||||
const page = ref<number>(1)
|
||||
const paginateBy = ref<number>(100)
|
||||
|
||||
// Search
|
||||
|
||||
const queryDebounced = refDebounced(query, 500)
|
||||
const trimmedQuery = computed(() => trim(trim(queryDebounced.value), '@'))
|
||||
const isUri = computed(() => trimmedQuery.value.startsWith('http://') || trimmedQuery.value.startsWith('https://') || trimmedQuery.value.includes('@'))
|
||||
|
||||
const isLoading = ref(false)
|
||||
const results = ref<null | components['schemas']['SearchResult']>(null)
|
||||
|
||||
const search = async () => {
|
||||
|
||||
// If query has the shape of a federated object, search the federation
|
||||
|
||||
|
||||
// Else, use the user database endpoint to search
|
||||
|
||||
|
||||
if (queryDebounced.value.length < 1) {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
const params = {
|
||||
query: queryDebounced.value
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get<components['schemas']['SearchResult']>('search/', { params })
|
||||
results.value = response.data
|
||||
} catch (error) {
|
||||
useErrorHandler(error as Error)
|
||||
results.value = null
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
watch(queryDebounced, search, { immediate: true })
|
||||
|
||||
// Filter
|
||||
|
||||
type QueryType = 'artists' | 'albums' | 'tracks' | 'playlists' | 'tags' | 'radios' | 'podcasts' | 'series' | 'rss'
|
||||
type Category = 'artists' | 'albums' | 'tracks' | 'playlists' | 'tags' | 'radios' | 'podcasts' | 'series' | 'rss' | 'federation'
|
||||
|
||||
type SearchType = {
|
||||
id: QueryType
|
||||
label: string
|
||||
includeChannels?: boolean
|
||||
contentCategory?: string
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
const types = computed(() => [
|
||||
const categories = computed(() => [
|
||||
{
|
||||
id: 'artists',
|
||||
type: 'artists',
|
||||
label: t('views.Search.label.artists'),
|
||||
includeChannels: true,
|
||||
contentCategory: 'music'
|
||||
endpoint: '/search',
|
||||
params: {
|
||||
contentCategory: 'music',
|
||||
includeChannels: 'true'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'albums',
|
||||
type: 'albums',
|
||||
label: t('views.Search.label.albums'),
|
||||
includeChannels: true,
|
||||
contentCategory: 'music'
|
||||
endpoint: '/search',
|
||||
params: {
|
||||
includeChannels: 'true',
|
||||
contentCategory: 'music'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tracks',
|
||||
label: t('views.Search.label.tracks')
|
||||
type: 'tracks',
|
||||
label: t('views.Search.label.tracks'),
|
||||
endpoint: '/search'
|
||||
},
|
||||
{
|
||||
id: 'playlists',
|
||||
label: t('views.Search.label.playlists')
|
||||
type: 'playlists',
|
||||
label: t('views.Search.label.playlists'),
|
||||
endpoint: '/TODO'
|
||||
},
|
||||
{
|
||||
id: 'radios',
|
||||
type: 'radios',
|
||||
label: t('views.Search.label.radios'),
|
||||
endpoint: 'radios/radios'
|
||||
endpoint: '/radios/radios'
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
label: t('views.Search.label.tags')
|
||||
type: 'tags',
|
||||
label: t('views.Search.label.tags'),
|
||||
endpoint: '/search'
|
||||
},
|
||||
{
|
||||
id: 'podcasts',
|
||||
type: 'podcasts',
|
||||
label: t('views.Search.label.podcasts'),
|
||||
endpoint: '/artists',
|
||||
contentCategory: 'podcast',
|
||||
includeChannels: true
|
||||
params: {
|
||||
contentCategory: 'podcast',
|
||||
includeChannels: 'true'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'series',
|
||||
type: 'series',
|
||||
label: t('views.Search.label.series'),
|
||||
endpoint: '/albums',
|
||||
includeChannels: true,
|
||||
contentCategory: 'podcast'
|
||||
params: {
|
||||
contentCategory: 'podcast',
|
||||
includeChannels: 'true'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'rss',
|
||||
label: t('views.Search.label.rss'),
|
||||
endpoint: '/channels/rss-subscribe/',
|
||||
params: {
|
||||
object: trimmedQuery.value
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'federation',
|
||||
label: t('views.Search.label.fetches'),
|
||||
endpoint: '/federation/fetches/',
|
||||
params: {
|
||||
object: trimmedQuery.value
|
||||
}
|
||||
}
|
||||
] as const satisfies SearchType[])
|
||||
] as const satisfies {
|
||||
type: Category
|
||||
label: string
|
||||
params?: {
|
||||
[key: string]: string
|
||||
}
|
||||
endpoint: `/${string}`
|
||||
}[])
|
||||
|
||||
// Limit the available categories based on the search query
|
||||
const federatedCategories: Category[] = ['federation', 'rss']
|
||||
|
||||
const availableCategories = computed(() =>
|
||||
categories.value.filter(({ type }) =>
|
||||
isUri.value
|
||||
? federatedCategories.includes(type)
|
||||
: !federatedCategories.includes(type)
|
||||
))
|
||||
|
||||
const currentCategory = computed(() => categories.value.find(({ type }) => type === openSection.value.at(0)))
|
||||
|
||||
// Display
|
||||
|
||||
const implementedType = (id: QueryType | undefined) =>
|
||||
id && id !== 'playlists' && id !== 'radios' && id !== 'podcasts' && id !== 'series' && id !== 'rss'
|
||||
? id
|
||||
: undefined
|
||||
// Parse the category to match a field in the SearchResults type
|
||||
type ExpectedCategory = keyof components['schemas']['SearchResult']
|
||||
const expectedCategories: ExpectedCategory[] = ['artists', 'albums', 'tracks', 'tags'] satisfies Category[]
|
||||
const parseCategoryExpected = (category: Category | undefined): ExpectedCategory | undefined =>
|
||||
expectedCategories.find(expected => category === expected)
|
||||
|
||||
// If currently open section has no items, or no section is open, then switch to the first section with any results:
|
||||
watch(results, () => {
|
||||
if (!results.value || results.value === null) return;
|
||||
|
||||
// If currently open section has no items, or no section is open, then switch to the first section with any items
|
||||
|
||||
const noOpenSection = openSection.value.length === 0
|
||||
|
||||
const currentImplementedSection = implementedType(openSection.value.at(0))
|
||||
const currentSectionExpected = parseCategoryExpected(openSection.value.at(0))
|
||||
|
||||
const currentImplementedSectionIsEmpty = () => currentImplementedSection && results.value?.[currentImplementedSection].length === 0
|
||||
const currentImplementedSectionIsEmpty = () => currentSectionExpected && results.value?.[currentSectionExpected].length === 0
|
||||
|
||||
if (noOpenSection || currentImplementedSectionIsEmpty()) {
|
||||
const firstTypeWithResults = types.value.find(({ id }) => {
|
||||
const idIsImplemented = implementedType(id)
|
||||
return idIsImplemented && results.value?.[idIsImplemented].length && results.value?.[idIsImplemented].length > 0
|
||||
const firstCategoryWithResults = categories.value.find(({ type }) => {
|
||||
const categoryExpectedInResults = parseCategoryExpected(type)
|
||||
return categoryExpectedInResults && results.value?.[categoryExpectedInResults].length && results.value?.[categoryExpectedInResults].length > 0
|
||||
})
|
||||
if (firstTypeWithResults)
|
||||
openSection.value.unshift(firstTypeWithResults.id)
|
||||
if (firstCategoryWithResults)
|
||||
openSection.value.unshift(firstCategoryWithResults.type)
|
||||
}
|
||||
})
|
||||
|
||||
// Show one section at a time (Accordion behaviour; clicking an open section navigates to the previous section)
|
||||
|
||||
const openSection = ref<QueryType[]>([])
|
||||
const openSection = ref<Category[]>([])
|
||||
|
||||
const toggle = (id: QueryType): void => {
|
||||
const toggle = (id: Category): void => {
|
||||
if (id === openSection.value.at(0)) {
|
||||
openSection.value.shift()
|
||||
} else {
|
||||
|
@ -161,6 +180,44 @@ const toggle = (id: QueryType): void => {
|
|||
}
|
||||
}
|
||||
|
||||
const search = async () => {
|
||||
|
||||
// Close if query is empty
|
||||
if (trimmedQuery.value.length < 1) {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// If query has the shape of an Uri, search the federation
|
||||
// Else, use the user database endpoint to search
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: queryDebounced.value,
|
||||
page: page.value.toString(),
|
||||
page_size: paginateBy.value.toString(),
|
||||
...(currentCategory.value && 'params' in currentCategory.value && currentCategory.value.params
|
||||
? currentCategory.value.params
|
||||
: {}
|
||||
)
|
||||
})
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await axios.get<components['schemas']['SearchResult']>(
|
||||
currentCategory.value?.endpoint || availableCategories.value[0].endpoint,
|
||||
{ params }
|
||||
)
|
||||
results.value = response.data
|
||||
} catch (error) {
|
||||
useErrorHandler(error as Error)
|
||||
results.value = null
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
watch(queryDebounced, search, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -186,27 +243,32 @@ const toggle = (id: QueryType): void => {
|
|||
</template>
|
||||
<Spacer />
|
||||
<template
|
||||
v-for="type in types"
|
||||
:key="type.id"
|
||||
v-for="category in availableCategories"
|
||||
:key="category.type"
|
||||
>
|
||||
<Section
|
||||
:action="{ text: type.label, onClick: () => { toggle(type.id) } }"
|
||||
:action="{ text: category.label, onClick: () => { toggle(category.type) } }"
|
||||
tiny-items
|
||||
align-left
|
||||
>
|
||||
<!-- If the category is expected to yield results, display the results in Cards or Activities -->
|
||||
<template
|
||||
v-for="(result, index) in (results && type.id !== 'playlists' && type.id !== 'radios' && type.id !== 'podcasts' && type.id !== 'series' ? results[type.id] : [])"
|
||||
:key="type.id+index"
|
||||
v-for="(result, index) in (results && parseCategoryExpected(category.type) ? results[category.type as ExpectedCategory] : [])"
|
||||
:key="category.type+index"
|
||||
>
|
||||
{{ result }}
|
||||
|
||||
<ArtistCard
|
||||
v-if="type.id === 'artists'"
|
||||
v-if="category.type === 'artists'"
|
||||
:artist="result"
|
||||
/>
|
||||
|
||||
<!-- TODO: Implement all the other cards here -->
|
||||
</template>
|
||||
|
||||
<!-- TODO: Implement unexpected results here (federated/uri results etc.) -->
|
||||
|
||||
<!-- If response has "url": "webfinger://node1@node1.funkwhale.test" -> Link to go directly to the federation page -->
|
||||
</Section>
|
||||
<Spacer />
|
||||
</template>
|
||||
|
|
Loading…
Reference in New Issue