feat(front): #2421 search modal uses different endpoints depending on query and user-chosen category (section)

This commit is contained in:
upsiflu 2025-03-14 17:59:27 +01:00
parent 806f49e061
commit 58341b107b
2 changed files with 152 additions and 85 deletions

View File

@ -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

View File

@ -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>