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<{
|
||||
[M in 'no-items' | 'tiny-items' | 'small-items' | 'medium-items']?: true }
|
||||
& { alignLeft?: boolean;}
|
||||
& {
|
||||
alignLeft?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
& { [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
|
||||
= props.h1
|
||||
|
@ -54,6 +57,7 @@ const headerGrid
|
|||
style="align-self: baseline;"
|
||||
/>
|
||||
<Heading
|
||||
v-if="heading"
|
||||
v-bind="heading"
|
||||
style="align-self: baseline; padding:0 0 24px 0; margin:0;"
|
||||
/>
|
||||
|
@ -64,22 +68,52 @@ const headerGrid
|
|||
v-if="action"
|
||||
ghost
|
||||
thin-font
|
||||
min-content
|
||||
align-self="baseline"
|
||||
:style="`margin-right: ${('primary' in props || 'secondary' in props || 'destructive' in props) ? '0px' : '-16px'}`"
|
||||
v-bind="{...fallthroughProps, ...action}"
|
||||
:align-text="'collapsed' in props ? 'start' : undefined"
|
||||
: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 }}
|
||||
</component>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
<!-- Love: https://css-tricks.com/css-grid-can-do-auto-height-transitions/ -->
|
||||
|
||||
<Layout
|
||||
main
|
||||
style="position:relative;"
|
||||
:style="'alignLeft' in props && props.alignLeft ? 'justify-content: start' : ''"
|
||||
:style="`${
|
||||
'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)"
|
||||
>
|
||||
<slot />
|
||||
</Layout>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
.action {
|
||||
&.transparent {
|
||||
margin-right: 16px;
|
||||
}
|
||||
&.full {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3330,7 +3330,9 @@
|
|||
"radios": "Radios",
|
||||
"series": "Series",
|
||||
"tags": "Tags",
|
||||
"tracks": "Tracks"
|
||||
"tracks": "Tracks",
|
||||
"rss": "RSS feeds",
|
||||
"federation": "Federation"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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 { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
|
@ -39,9 +39,16 @@ 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)
|
||||
const isFocusingSearch = ref<true | undefined>(undefined)
|
||||
const focusSearch = () => {
|
||||
isFocusingSearch.value = undefined
|
||||
nextTick(() => {
|
||||
isFocusingSearch.value = true
|
||||
})
|
||||
}
|
||||
onKeyboardShortcut(['shift', 'f'], focusSearch, true)
|
||||
onKeyboardShortcut(['ctrl', 'k'], focusSearch, true)
|
||||
onKeyboardShortcut(['/'], focusSearch, true)
|
||||
|
||||
// Admin notifications
|
||||
|
||||
|
@ -194,8 +201,9 @@ const moderationNotifications = computed(() =>
|
|||
:class="[$style['menu-links'], isCollapsed && 'hide-on-mobile']"
|
||||
>
|
||||
<Input
|
||||
ref="search"
|
||||
:key="isFocusingSearch ? 1 : 0"
|
||||
v-model="searchParameter"
|
||||
:autofocus="isFocusingSearch"
|
||||
raised
|
||||
autocomplete="search"
|
||||
type="search"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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 { ref, watch, computed } from 'vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
|
@ -9,12 +9,21 @@ 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 TagsList from '~/components/tags/List.vue'
|
||||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import Input from '~/components/ui/Input.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()
|
||||
|
||||
|
@ -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 isLoading = ref(false)
|
||||
const results = ref<null | components['schemas']['SearchResult']>(null)
|
||||
|
||||
// Filter
|
||||
|
||||
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(() => [
|
||||
{
|
||||
type: 'artists',
|
||||
|
@ -70,6 +93,11 @@ const categories = computed(() => [
|
|||
label: t('views.Search.label.tracks'),
|
||||
endpoint: '/search'
|
||||
},
|
||||
{
|
||||
type: 'tags',
|
||||
label: t('views.Search.label.tags'),
|
||||
endpoint: '/search'
|
||||
},
|
||||
{
|
||||
type: 'playlists',
|
||||
label: t('views.Search.label.playlists'),
|
||||
|
@ -80,11 +108,6 @@ const categories = computed(() => [
|
|||
label: t('views.Search.label.radios'),
|
||||
endpoint: '/radios/radios'
|
||||
},
|
||||
{
|
||||
type: 'tags',
|
||||
label: t('views.Search.label.tags'),
|
||||
endpoint: '/search'
|
||||
},
|
||||
{
|
||||
type: 'podcasts',
|
||||
label: t('views.Search.label.podcasts'),
|
||||
|
@ -138,83 +161,110 @@ const availableCategories = computed(() =>
|
|||
: !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
|
||||
// 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
|
||||
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)
|
||||
type SearchResultCategories = keyof components['schemas']['SearchResult']
|
||||
const searchResultCategories: SearchResultCategories[] = ['artists', 'albums', 'tracks', 'tags'] satisfies Category[]
|
||||
const parseCategoryExpected = (category: Category | undefined): SearchResultCategories | undefined =>
|
||||
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, () => {
|
||||
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))
|
||||
|
||||
const currentImplementedSectionIsEmpty = () => currentSectionExpected && results.value?.[currentSectionExpected].length === 0
|
||||
|
||||
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)
|
||||
}
|
||||
// Then open it!
|
||||
if (firstCategoryWithResults)
|
||||
openSectionHistory.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 => {
|
||||
if (id === openSection.value.at(0)) {
|
||||
openSection.value.shift()
|
||||
const toggleSection = (id: Category): void => {
|
||||
if (id === openSectionHistory.value.at(0)) {
|
||||
openSectionHistory.value.shift()
|
||||
} else {
|
||||
openSection.value.unshift(id)
|
||||
openSectionHistory.value.unshift(id)
|
||||
}
|
||||
}
|
||||
|
||||
const search = async () => {
|
||||
|
||||
// Close if query is empty
|
||||
if (trimmedQuery.value.length < 1) {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
// 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
|
||||
// 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
|
||||
: {}
|
||||
)
|
||||
})
|
||||
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
|
||||
// Query either the user-secelcted category or the first one that is compatible with the query
|
||||
const category = currentCategory.value || availableCategories.value[0]
|
||||
|
||||
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 = true
|
||||
|
||||
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 })
|
||||
|
@ -242,35 +292,86 @@ watch(queryDebounced, search, { immediate: true })
|
|||
</Button>
|
||||
</template>
|
||||
<Spacer />
|
||||
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
/>
|
||||
|
||||
<template
|
||||
v-for="category in availableCategories"
|
||||
:key="category.type"
|
||||
>
|
||||
<!-- Each section collapses if it is not current -->
|
||||
<Section
|
||||
:action="{ text: category.label, onClick: () => { toggle(category.type) } }"
|
||||
tiny-items
|
||||
align-left
|
||||
:action="{
|
||||
text: `${
|
||||
!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 -->
|
||||
<template
|
||||
v-for="(result, index) in (results && parseCategoryExpected(category.type) ? results[category.type as ExpectedCategory] : [])"
|
||||
:key="category.type+index"
|
||||
v-for="(result, index) in (resultsPerCategory(category.type))"
|
||||
:key="category.type + index"
|
||||
>
|
||||
{{ result }}
|
||||
|
||||
<ArtistCard
|
||||
v-if="category.type === 'artists'"
|
||||
v-if="category.type === 'artists' || category.type === 'podcasts'"
|
||||
: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>
|
||||
|
||||
<!-- 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 -->
|
||||
</Section>
|
||||
<Spacer />
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
Loading…
Reference in New Issue