feat(front): #2421 some more sweet search modal features

This commit is contained in:
upsiflu 2025-03-15 13:52:17 +01:00
parent 58341b107b
commit 7609bdca3f
4 changed files with 231 additions and 86 deletions

View File

@ -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>

View File

@ -3330,7 +3330,9 @@
"radios": "Radios",
"series": "Series",
"tags": "Tags",
"tracks": "Tracks"
"tracks": "Tracks",
"rss": "RSS feeds",
"federation": "Federation"
}
},
"admin": {

View File

@ -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"

View File

@ -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>