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
	
	 jon r
						jon r