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) 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 // Admin notifications

View File

@ -3,6 +3,7 @@ import { type components } from '~/generated/types.ts'
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 useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -10,7 +11,6 @@ import { useModal } from '~/ui/composables/useModal.ts'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
import Input from '~/components/ui/Input.vue' import Input from '~/components/ui/Input.vue'
import Section from '~/components/ui/Section.vue' import Section from '~/components/ui/Section.vue'
@ -24,136 +24,155 @@ const startRadio = () => {
// TODO: Start the radio // 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 // Search
const queryDebounced = refDebounced(query, 500) 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 isLoading = ref(false)
const results = ref<null | components['schemas']['SearchResult']>(null) 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 // 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 = { const categories = computed(() => [
id: QueryType
label: string
includeChannels?: boolean
contentCategory?: string
endpoint?: string
}
const types = computed(() => [
{ {
id: 'artists', type: 'artists',
label: t('views.Search.label.artists'), label: t('views.Search.label.artists'),
includeChannels: true, endpoint: '/search',
contentCategory: 'music' params: {
contentCategory: 'music',
includeChannels: 'true'
}
}, },
{ {
id: 'albums', type: 'albums',
label: t('views.Search.label.albums'), label: t('views.Search.label.albums'),
includeChannels: true, endpoint: '/search',
params: {
includeChannels: 'true',
contentCategory: 'music' contentCategory: 'music'
}
}, },
{ {
id: 'tracks', type: 'tracks',
label: t('views.Search.label.tracks') label: t('views.Search.label.tracks'),
endpoint: '/search'
}, },
{ {
id: 'playlists', type: 'playlists',
label: t('views.Search.label.playlists') label: t('views.Search.label.playlists'),
endpoint: '/TODO'
}, },
{ {
id: 'radios', type: 'radios',
label: t('views.Search.label.radios'), label: t('views.Search.label.radios'),
endpoint: 'radios/radios' endpoint: '/radios/radios'
}, },
{ {
id: 'tags', type: 'tags',
label: t('views.Search.label.tags') label: t('views.Search.label.tags'),
endpoint: '/search'
}, },
{ {
id: 'podcasts', type: 'podcasts',
label: t('views.Search.label.podcasts'), label: t('views.Search.label.podcasts'),
endpoint: '/artists', endpoint: '/artists',
params: {
contentCategory: 'podcast', contentCategory: 'podcast',
includeChannels: true includeChannels: 'true'
}
}, },
{ {
id: 'series', type: 'series',
label: t('views.Search.label.series'), label: t('views.Search.label.series'),
endpoint: '/albums', endpoint: '/albums',
includeChannels: true, params: {
contentCategory: 'podcast' contentCategory: 'podcast',
includeChannels: 'true'
} }
] as const satisfies SearchType[]) },
{
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 {
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 // Display
const implementedType = (id: QueryType | undefined) => // Parse the category to match a field in the SearchResults type
id && id !== 'playlists' && id !== 'radios' && id !== 'podcasts' && id !== 'series' && id !== 'rss' type ExpectedCategory = keyof components['schemas']['SearchResult']
? id const expectedCategories: ExpectedCategory[] = ['artists', 'albums', 'tracks', 'tags'] satisfies Category[]
: undefined 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, () => { watch(results, () => {
if (!results.value || results.value === null) return; 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 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()) { if (noOpenSection || currentImplementedSectionIsEmpty()) {
const firstTypeWithResults = types.value.find(({ id }) => { const firstCategoryWithResults = categories.value.find(({ type }) => {
const idIsImplemented = implementedType(id) const categoryExpectedInResults = parseCategoryExpected(type)
return idIsImplemented && results.value?.[idIsImplemented].length && results.value?.[idIsImplemented].length > 0 return categoryExpectedInResults && results.value?.[categoryExpectedInResults].length && results.value?.[categoryExpectedInResults].length > 0
}) })
if (firstTypeWithResults) if (firstCategoryWithResults)
openSection.value.unshift(firstTypeWithResults.id) openSection.value.unshift(firstCategoryWithResults.type)
} }
}) })
// Show one section at a time (Accordion behaviour; clicking an open section navigates to the previous section) // 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)) { if (id === openSection.value.at(0)) {
openSection.value.shift() openSection.value.shift()
} else { } 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> </script>
<template> <template>
@ -186,27 +243,32 @@ const toggle = (id: QueryType): void => {
</template> </template>
<Spacer /> <Spacer />
<template <template
v-for="type in types" v-for="category in availableCategories"
:key="type.id" :key="category.type"
> >
<Section <Section
:action="{ text: type.label, onClick: () => { toggle(type.id) } }" :action="{ text: category.label, onClick: () => { toggle(category.type) } }"
tiny-items tiny-items
align-left align-left
> >
<!-- If the category is expected to yield results, display the results in Cards or Activities -->
<template <template
v-for="(result, index) in (results && type.id !== 'playlists' && type.id !== 'radios' && type.id !== 'podcasts' && type.id !== 'series' ? results[type.id] : [])" v-for="(result, index) in (results && parseCategoryExpected(category.type) ? results[category.type as ExpectedCategory] : [])"
:key="type.id+index" :key="category.type+index"
> >
{{ result }} {{ result }}
<ArtistCard <ArtistCard
v-if="type.id === 'artists'" v-if="category.type === 'artists'"
:artist="result" :artist="result"
/> />
<!-- TODO: Implement all the other cards here --> <!-- TODO: Implement all the other cards here -->
</template> </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> </Section>
<Spacer /> <Spacer />
</template> </template>