From 58341b107bfbdd1fad646636ff0c865dc0f3ceb5 Mon Sep 17 00:00:00 2001 From: upsiflu Date: Fri, 14 Mar 2025 17:59:27 +0100 Subject: [PATCH] feat(front): #2421 search modal uses different endpoints depending on query and user-chosen category (section) --- front/src/ui/components/Sidebar.vue | 5 + front/src/ui/modals/Search.vue | 232 ++++++++++++++++++---------- 2 files changed, 152 insertions(+), 85 deletions(-) diff --git a/front/src/ui/components/Sidebar.vue b/front/src/ui/components/Sidebar.vue index 4cf4fbfa3..1a6fc4492 100644 --- a/front/src/ui/components/Sidebar.vue +++ b/front/src/ui/components/Sidebar.vue @@ -37,6 +37,11 @@ const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index' const isOpen = ref(false) +// Search bar focus + +const search = ref() +onKeyboardShortcut(['shift', 'f'], () => search.value?.focus(), true) +onKeyboardShortcut(['ctrl', 'k'], () => search.value?.focus(), true) // Admin notifications diff --git a/front/src/ui/modals/Search.vue b/front/src/ui/modals/Search.vue index bc317a02d..c2c0ff52a 100644 --- a/front/src/ui/modals/Search.vue +++ b/front/src/ui/modals/Search.vue @@ -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(1) +const paginateBy = ref(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) -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('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([]) +const openSection = ref([]) -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( + 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 })