feat(front): #2421 some more sweet search modal features
This commit is contained in:
parent
58341b107b
commit
7609bdca3f
|
@ -13,9 +13,12 @@ const actionComponents
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
[M in 'no-items' | 'tiny-items' | 'small-items' | 'medium-items']?: true }
|
[M in 'no-items' | 'tiny-items' | 'small-items' | 'medium-items']?: true }
|
||||||
& { alignLeft?: boolean;}
|
& {
|
||||||
|
alignLeft?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
& { [H in 'h1' | 'h2' | 'h3']?: string }
|
& { [H in 'h1' | 'h2' | 'h3']?: string }
|
||||||
& { action?: { text: string } &(RouterLinkProps | { onClick: (...args: any[]) => void | Promise<void> }) }>()
|
& { action?: { text: string } & (RouterLinkProps | { onClick: (...args: any[]) => void | Promise<void> }) }>()
|
||||||
|
|
||||||
const heading
|
const heading
|
||||||
= props.h1
|
= props.h1
|
||||||
|
@ -54,6 +57,7 @@ const headerGrid
|
||||||
style="align-self: baseline;"
|
style="align-self: baseline;"
|
||||||
/>
|
/>
|
||||||
<Heading
|
<Heading
|
||||||
|
v-if="heading"
|
||||||
v-bind="heading"
|
v-bind="heading"
|
||||||
style="align-self: baseline; padding:0 0 24px 0; margin:0;"
|
style="align-self: baseline; padding:0 0 24px 0; margin:0;"
|
||||||
/>
|
/>
|
||||||
|
@ -64,22 +68,52 @@ const headerGrid
|
||||||
v-if="action"
|
v-if="action"
|
||||||
ghost
|
ghost
|
||||||
thin-font
|
thin-font
|
||||||
min-content
|
|
||||||
align-self="baseline"
|
align-self="baseline"
|
||||||
:style="`margin-right: ${('primary' in props || 'secondary' in props || 'destructive' in props) ? '0px' : '-16px'}`"
|
:align-text="'collapsed' in props ? 'start' : undefined"
|
||||||
v-bind="{...fallthroughProps, ...action}"
|
:aria-pressed="props.collapsed === false || undefined"
|
||||||
|
:class="{
|
||||||
|
[$style.action]: true,
|
||||||
|
[$style.transparent]: 'primary' in props || 'secondary' in props || 'destructive' in props,
|
||||||
|
[$style.full]: 'collapsed' in props
|
||||||
|
}"
|
||||||
|
v-bind="{...fallthroughProps, ...action, ['collapsed' in props ? 'full' : 'min-content']: true}"
|
||||||
>
|
>
|
||||||
{{ action?.text }}
|
{{ action?.text }}
|
||||||
</component>
|
</component>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<!-- Love: https://css-tricks.com/css-grid-can-do-auto-height-transitions/ -->
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
main
|
main
|
||||||
style="position:relative;"
|
:style="`${
|
||||||
:style="'alignLeft' in props && props.alignLeft ? 'justify-content: start' : ''"
|
'alignLeft' in props && props.alignLeft
|
||||||
|
? 'justify-content: start;'
|
||||||
|
: ''
|
||||||
|
} ${
|
||||||
|
'collapsed' in props && props.collapsed
|
||||||
|
? 'grid-template-rows: 0fr; overflow: hidden; max-height: 0;'
|
||||||
|
: 'max-height: 4000px;'
|
||||||
|
}
|
||||||
|
position: relative;
|
||||||
|
transition: max-height .5s, grid-template-rows .3s;`
|
||||||
|
"
|
||||||
grid="auto / repeat(auto-fit, 46px)"
|
grid="auto / repeat(auto-fit, 46px)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Layout>
|
</Layout>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
.action {
|
||||||
|
&.transparent {
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
&.full {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -3330,7 +3330,9 @@
|
||||||
"radios": "Radios",
|
"radios": "Radios",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"tracks": "Tracks"
|
"tracks": "Tracks",
|
||||||
|
"rss": "RSS feeds",
|
||||||
|
"federation": "Federation"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, computed } from 'vue'
|
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||||
import { useUploadsStore } from '../stores/upload'
|
import { useUploadsStore } from '../stores/upload'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
@ -39,9 +39,16 @@ const isOpen = ref(false)
|
||||||
|
|
||||||
// Search bar focus
|
// Search bar focus
|
||||||
|
|
||||||
const search = ref<HTMLInputElement>()
|
const isFocusingSearch = ref<true | undefined>(undefined)
|
||||||
onKeyboardShortcut(['shift', 'f'], () => search.value?.focus(), true)
|
const focusSearch = () => {
|
||||||
onKeyboardShortcut(['ctrl', 'k'], () => search.value?.focus(), true)
|
isFocusingSearch.value = undefined
|
||||||
|
nextTick(() => {
|
||||||
|
isFocusingSearch.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onKeyboardShortcut(['shift', 'f'], focusSearch, true)
|
||||||
|
onKeyboardShortcut(['ctrl', 'k'], focusSearch, true)
|
||||||
|
onKeyboardShortcut(['/'], focusSearch, true)
|
||||||
|
|
||||||
// Admin notifications
|
// Admin notifications
|
||||||
|
|
||||||
|
@ -194,8 +201,9 @@ const moderationNotifications = computed(() =>
|
||||||
:class="[$style['menu-links'], isCollapsed && 'hide-on-mobile']"
|
:class="[$style['menu-links'], isCollapsed && 'hide-on-mobile']"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
ref="search"
|
:key="isFocusingSearch ? 1 : 0"
|
||||||
v-model="searchParameter"
|
v-model="searchParameter"
|
||||||
|
:autofocus="isFocusingSearch"
|
||||||
raised
|
raised
|
||||||
autocomplete="search"
|
autocomplete="search"
|
||||||
type="search"
|
type="search"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type components } from '~/generated/types.ts'
|
import { type components, type paths } 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'
|
||||||
|
@ -9,12 +9,21 @@ import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useModal } from '~/ui/composables/useModal.ts'
|
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 TagsList from '~/components/tags/List.vue'
|
||||||
|
|
||||||
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 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'
|
||||||
import ArtistCard from '~/components/artist/Card.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 { t } = useI18n()
|
||||||
|
|
||||||
|
@ -40,12 +49,26 @@ const trimmedQuery = computed(() => trim(trim(queryDebounced.value), '@'))
|
||||||
const isUri = computed(() => trimmedQuery.value.startsWith('http://') || trimmedQuery.value.startsWith('https://') || trimmedQuery.value.includes('@'))
|
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)
|
|
||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
|
|
||||||
type Category = 'artists' | 'albums' | 'tracks' | 'playlists' | 'tags' | 'radios' | 'podcasts' | 'series' | 'rss' | 'federation'
|
type Category = 'artists' | 'albums' | 'tracks' | 'playlists' | 'tags' | 'radios' | 'podcasts' | 'series' | 'rss' | 'federation'
|
||||||
|
|
||||||
|
type Results = {
|
||||||
|
artists: components['schemas']['SearchResult']['artists'],
|
||||||
|
albums: components['schemas']['SearchResult']['albums'],
|
||||||
|
tracks: components['schemas']['SearchResult']['tracks'],
|
||||||
|
tags: components['schemas']['SearchResult']['tags'],
|
||||||
|
playlists: components['schemas']['Playlist'][],
|
||||||
|
radios: components['schemas']['Radio'][],
|
||||||
|
podcasts: components['schemas']['Artist'][],
|
||||||
|
series: components['schemas']['Album'][],
|
||||||
|
rss: components['schemas']['Channel'][],
|
||||||
|
federation: components['schemas']['Fetch'][]
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = ref<Partial<Results>>()
|
||||||
|
|
||||||
const categories = computed(() => [
|
const categories = computed(() => [
|
||||||
{
|
{
|
||||||
type: 'artists',
|
type: 'artists',
|
||||||
|
@ -70,6 +93,11 @@ const categories = computed(() => [
|
||||||
label: t('views.Search.label.tracks'),
|
label: t('views.Search.label.tracks'),
|
||||||
endpoint: '/search'
|
endpoint: '/search'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'tags',
|
||||||
|
label: t('views.Search.label.tags'),
|
||||||
|
endpoint: '/search'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'playlists',
|
type: 'playlists',
|
||||||
label: t('views.Search.label.playlists'),
|
label: t('views.Search.label.playlists'),
|
||||||
|
@ -80,11 +108,6 @@ const categories = computed(() => [
|
||||||
label: t('views.Search.label.radios'),
|
label: t('views.Search.label.radios'),
|
||||||
endpoint: '/radios/radios'
|
endpoint: '/radios/radios'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: 'tags',
|
|
||||||
label: t('views.Search.label.tags'),
|
|
||||||
endpoint: '/search'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'podcasts',
|
type: 'podcasts',
|
||||||
label: t('views.Search.label.podcasts'),
|
label: t('views.Search.label.podcasts'),
|
||||||
|
@ -138,83 +161,110 @@ const availableCategories = computed(() =>
|
||||||
: !federatedCategories.includes(type)
|
: !federatedCategories.includes(type)
|
||||||
))
|
))
|
||||||
|
|
||||||
const currentCategory = computed(() => categories.value.find(({ type }) => type === openSection.value.at(0)))
|
/**
|
||||||
|
* Get the results for a given category
|
||||||
|
* @param category The category to get the results for
|
||||||
|
* @returns The results for the given category
|
||||||
|
*/
|
||||||
|
const resultsPerCategory = (category: Category) =>
|
||||||
|
results.value?.[category] || []
|
||||||
|
|
||||||
|
const isCategoryQueried = (category: Category) =>
|
||||||
|
results.value?.[category] ? true : false
|
||||||
|
|
||||||
// Display
|
// Display
|
||||||
|
// At most one section is open ('current') at a given moment. It determines which category is 'current'.
|
||||||
|
|
||||||
|
const currentCategory = computed(() => categories.value.find(({ type }) => type === openSectionHistory.value.at(0)))
|
||||||
|
|
||||||
// Parse the category to match a field in the SearchResults type
|
// Parse the category to match a field in the SearchResults type
|
||||||
type ExpectedCategory = keyof components['schemas']['SearchResult']
|
type SearchResultCategories = keyof components['schemas']['SearchResult']
|
||||||
const expectedCategories: ExpectedCategory[] = ['artists', 'albums', 'tracks', 'tags'] satisfies Category[]
|
const searchResultCategories: SearchResultCategories[] = ['artists', 'albums', 'tracks', 'tags'] satisfies Category[]
|
||||||
const parseCategoryExpected = (category: Category | undefined): ExpectedCategory | undefined =>
|
const parseCategoryExpected = (category: Category | undefined): SearchResultCategories | undefined =>
|
||||||
expectedCategories.find(expected => category === expected)
|
searchResultCategories.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;
|
// Currently open section has some items?
|
||||||
|
if ((currentCategory.value && results.value?.[currentCategory.value.type]?.length) || 0 > 0)
|
||||||
|
return
|
||||||
|
|
||||||
const noOpenSection = openSection.value.length === 0
|
// Any other section has some items?
|
||||||
|
const firstCategoryWithResults = categories.value.find(category =>
|
||||||
|
(results.value?.[category.type]?.length || 0) > 0
|
||||||
|
)
|
||||||
|
|
||||||
const currentSectionExpected = parseCategoryExpected(openSection.value.at(0))
|
// Then open it!
|
||||||
|
if (firstCategoryWithResults)
|
||||||
const currentImplementedSectionIsEmpty = () => currentSectionExpected && results.value?.[currentSectionExpected].length === 0
|
openSectionHistory.value.unshift(firstCategoryWithResults.type)
|
||||||
|
|
||||||
if (noOpenSection || currentImplementedSectionIsEmpty()) {
|
|
||||||
const firstCategoryWithResults = categories.value.find(({ type }) => {
|
|
||||||
const categoryExpectedInResults = parseCategoryExpected(type)
|
|
||||||
return categoryExpectedInResults && results.value?.[categoryExpectedInResults].length && results.value?.[categoryExpectedInResults].length > 0
|
|
||||||
})
|
|
||||||
if (firstCategoryWithResults)
|
|
||||||
openSection.value.unshift(firstCategoryWithResults.type)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show one section at a time (Accordion behaviour; clicking an open section navigates to the previous section)
|
// Showing one section at a time (Accordion behaviour; clicking an open section navigates to the previous section)
|
||||||
|
|
||||||
const openSection = ref<Category[]>([])
|
const openSectionHistory = ref<Category[]>([])
|
||||||
|
|
||||||
const toggle = (id: Category): void => {
|
const toggleSection = (id: Category): void => {
|
||||||
if (id === openSection.value.at(0)) {
|
if (id === openSectionHistory.value.at(0)) {
|
||||||
openSection.value.shift()
|
openSectionHistory.value.shift()
|
||||||
} else {
|
} else {
|
||||||
openSection.value.unshift(id)
|
openSectionHistory.value.unshift(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = async () => {
|
const search = async () => {
|
||||||
|
|
||||||
// Close if query is empty
|
// Close if query is empty
|
||||||
if (trimmedQuery.value.length < 1) {
|
if (trimmedQuery.value.length < 1) {
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If query has the shape of an Uri, search the federation
|
// If query has the shape of an Uri, search the federation
|
||||||
// Else, use the user database endpoint to search
|
// Else, use the user database endpoint to search
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
q: queryDebounced.value,
|
q: queryDebounced.value,
|
||||||
page: page.value.toString(),
|
page: page.value.toString(),
|
||||||
page_size: paginateBy.value.toString(),
|
page_size: paginateBy.value.toString(),
|
||||||
...(currentCategory.value && 'params' in currentCategory.value && currentCategory.value.params
|
...(currentCategory.value && 'params' in currentCategory.value && currentCategory.value.params
|
||||||
? currentCategory.value.params
|
? currentCategory.value.params
|
||||||
: {}
|
: {}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
isLoading.value = true
|
// Query either the user-secelcted category or the first one that is compatible with the query
|
||||||
|
const category = currentCategory.value || availableCategories.value[0]
|
||||||
|
|
||||||
try {
|
isLoading.value = true
|
||||||
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
|
try {
|
||||||
|
const searchResultCategory = parseCategoryExpected(category.type)
|
||||||
|
|
||||||
|
if (searchResultCategory) {
|
||||||
|
|
||||||
|
const response = await axios.get<components['schemas']['SearchResult']>(
|
||||||
|
category.endpoint,
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
results.value = {
|
||||||
|
...results.value,
|
||||||
|
...response.data
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await axios.get<Results[typeof category.type][0]>(
|
||||||
|
category.endpoint,
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
results.value = {
|
||||||
|
...results.value,
|
||||||
|
[category.type]: response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
useErrorHandler(error as Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(queryDebounced, search, { immediate: true })
|
watch(queryDebounced, search, { immediate: true })
|
||||||
|
@ -242,35 +292,86 @@ watch(queryDebounced, search, { immediate: true })
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
|
|
||||||
|
<Loader
|
||||||
|
v-if="isLoading"
|
||||||
|
/>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-for="category in availableCategories"
|
v-for="category in availableCategories"
|
||||||
:key="category.type"
|
:key="category.type"
|
||||||
>
|
>
|
||||||
|
<!-- Each section collapses if it is not current -->
|
||||||
<Section
|
<Section
|
||||||
:action="{ text: category.label, onClick: () => { toggle(category.type) } }"
|
:action="{
|
||||||
tiny-items
|
text: `${
|
||||||
align-left
|
!isCategoryQueried(category.type)
|
||||||
|
? '...'
|
||||||
|
: resultsPerCategory(category.type).length > 0
|
||||||
|
? `${resultsPerCategory(category.type).length} `
|
||||||
|
: ''
|
||||||
|
}${category.label}`,
|
||||||
|
onClick: () => {
|
||||||
|
toggleSection(category.type)
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
no-items
|
||||||
|
:collapsed="currentCategory?.type !== category.type"
|
||||||
>
|
>
|
||||||
|
<EmptyState
|
||||||
|
v-if="resultsPerCategory(category.type).length === 0"
|
||||||
|
style="grid-column: 1 / -1"
|
||||||
|
:refresh="true"
|
||||||
|
@refresh="search"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Categories that have one list-style item -->
|
||||||
|
|
||||||
|
<TrackTable
|
||||||
|
v-if="category.type === 'tracks'"
|
||||||
|
style="grid-column: 1 / -1"
|
||||||
|
:tracks="resultsPerCategory('tracks') as components['schemas']['SearchResult']['tracks']"
|
||||||
|
/>
|
||||||
|
<TagsList
|
||||||
|
v-else-if="category.type === 'tags'"
|
||||||
|
style="grid-column: 1 / -1"
|
||||||
|
:truncate-size="200"
|
||||||
|
:limit="paginateBy"
|
||||||
|
:tags="(resultsPerCategory('tags') as components['schemas']['SearchResult']['tags']).map(t => t.name)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Categories that show individual cards -->
|
||||||
<!-- If the category is expected to yield results, display the results in Cards or Activities -->
|
<!-- If the category is expected to yield results, display the results in Cards or Activities -->
|
||||||
<template
|
<template
|
||||||
v-for="(result, index) in (results && parseCategoryExpected(category.type) ? results[category.type as ExpectedCategory] : [])"
|
v-for="(result, index) in (resultsPerCategory(category.type))"
|
||||||
:key="category.type+index"
|
:key="category.type + index"
|
||||||
>
|
>
|
||||||
{{ result }}
|
|
||||||
|
|
||||||
<ArtistCard
|
<ArtistCard
|
||||||
v-if="category.type === 'artists'"
|
v-if="category.type === 'artists' || category.type === 'podcasts'"
|
||||||
:artist="result"
|
:artist="result"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- TODO: Implement all the other cards here -->
|
<AlbumCard
|
||||||
|
v-else-if="category.type === 'albums' || category.type === 'series'"
|
||||||
|
:album="result as components['schemas']['SearchResult']['albums'][0]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PlaylistCard
|
||||||
|
v-else-if="category.type === 'playlists'"
|
||||||
|
:playlist="result as components['schemas']['Playlist']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RadioCard
|
||||||
|
v-else-if="category.type === 'radios'"
|
||||||
|
type="custom"
|
||||||
|
:custom-radio="result as components['schemas']['Radio']"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- TODO: Implement unexpected results here (federated/uri results etc.) -->
|
<!-- TODO: Implement results from non-`/v2/search` requests (federated/uri results etc.) here -->
|
||||||
|
|
||||||
<!-- If response has "url": "webfinger://node1@node1.funkwhale.test" -> Link to go directly to the federation page -->
|
<!-- If response has "url": "webfinger://node1@node1.funkwhale.test" -> Link to go directly to the federation page -->
|
||||||
</Section>
|
</Section>
|
||||||
<Spacer />
|
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
Loading…
Reference in New Issue