Skip to content

Commit

Permalink
Add additional search view pages to the Nuxt app (#3140)
Browse files Browse the repository at this point in the history
* Add and update unit tests

* Add collections to the search&media stores & service

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Add collection page

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Add POC media fetching and collection header

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Make page matching more strict and set up page

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Add a test to validate-collection-params

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Do not show "0 results found" before fetch finished

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Use getCollectionPath from search store

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Simplify pages

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Fix load more

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Reset search state

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Set back to results path in single-result middleware

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Fix paddings

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Use Results type

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Remove page query param

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Refactor creatorHref

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Add requested changes

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Fix server rendering

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Add e2e tests

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Update bottom margin of the collection header

Signed-off-by: Olga Bulat <obulat@gmail.com>

* Fix e2e tests

Signed-off-by: Olga Bulat <obulat@gmail.com>

---------

Signed-off-by: Olga Bulat <obulat@gmail.com>
  • Loading branch information
obulat committed Dec 5, 2023
1 parent b5270e2 commit 6102985
Show file tree
Hide file tree
Showing 29 changed files with 3,533 additions and 56 deletions.
Expand Up @@ -75,6 +75,7 @@ export default defineComponent({
},
},
setup(props) {
const mediaStore = useMediaStore()
const providerStore = useProviderStore()
const uiStore = useUiStore()
Expand Down Expand Up @@ -114,7 +115,10 @@ export default defineComponent({
const { getI18nCollectionResultCountLabel } = useI18nResultsCount()
const resultsLabel = computed(() => {
const resultsCount = useMediaStore().results[props.mediaType].count
if (mediaStore.resultCount === 0 && mediaStore.fetchState.isFetching) {
return ""
}
const resultsCount = mediaStore.results[props.mediaType].count
if (props.collectionParams.collection === "creator") {
return getI18nCollectionResultCountLabel(
resultsCount,
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/components/VCollectionPage.vue
@@ -0,0 +1,106 @@
<template>
<div class="p-6 pt-0 lg:p-10 lg:pt-2">
<VCollectionHeader
v-if="collectionParams"
:collection-params="collectionParams"
:creator-url="creatorUrl"
:media-type="mediaType"
:class="mediaType === 'image' ? 'mb-4' : 'mb-2'"
/>
<VAudioCollection
v-if="results.type === 'audio'"
:collection-label="collectionLabel"
:fetch-state="fetchState"
kind="collection"
:results="results.items"
/>
<VImageGrid
v-if="results.type === 'image'"
:image-grid-label="collectionLabel"
:fetch-state="fetchState"
kind="collection"
:results="results.items"
/>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue"
import { useMediaStore } from "~/stores/media"
import { useSearchStore } from "~/stores/search"
import type { SupportedMediaType } from "~/constants/media"
import { Results } from "~/types/result"
import { useI18n } from "~/composables/use-i18n"
import VCollectionHeader from "~/components/VCollectionHeader/VCollectionHeader.vue"
import VAudioCollection from "~/components/VSearchResultsGrid/VAudioCollection.vue"
import VImageGrid from "~/components/VSearchResultsGrid/VImageGrid.vue"
export default defineComponent({
name: "VCollectionPage",
components: { VAudioCollection, VImageGrid, VCollectionHeader },
props: {
mediaType: {
type: String as PropType<SupportedMediaType>,
required: true,
},
},
setup(props) {
const i18n = useI18n()
const mediaStore = useMediaStore()
const fetchState = computed(() => mediaStore.fetchState)
const results = computed<Results>(() => {
return {
type: props.mediaType,
items: mediaStore.resultItems[props.mediaType],
} as Results
})
const creatorUrl = computed(() => {
const media = results.value.items
return media.length > 0 ? media[0].creator_url : undefined
})
const searchStore = useSearchStore()
const collectionParams = computed(() => searchStore.collectionParams)
const collectionLabel = computed(() => {
const collection = collectionParams.value?.collection
switch (collection) {
case "tag":
return i18n
.t(`collection.ariaLabel.tag.${props.mediaType}`, {
tag: collectionParams.value?.tag,
})
.toString()
case "source":
return i18n
.t(`collection.ariaLabel.source.${props.mediaType}`, {
source: collectionParams.value?.source,
})
.toString()
case "creator":
return i18n
.t(`collection.ariaLabel.creator.${props.mediaType}`, {
creator: collectionParams.value?.creator,
source: collectionParams.value?.source,
})
.toString()
default:
return ""
}
})
return {
fetchState,
results,
creatorUrl,
collectionParams,
collectionLabel,
}
},
})
</script>
21 changes: 14 additions & 7 deletions frontend/src/components/VLoadMore.vue
Expand Up @@ -46,19 +46,26 @@ export default defineComponent({
storeToRefs(mediaStore)
const { searchTerm } = storeToRefs(searchStore)
const searchStarted = computed(() => {
return searchStore.strategy === "default"
? searchTerm.value !== ""
: searchStore.collectionParams !== null
})
/**
* Whether we should show the "Load more" button.
* If the user has entered a search term, there is at least 1 page of results,
* there has been no fetching error, and there are more results to fetch,
* we show the button.
*/
const canLoadMore = computed(
() =>
searchTerm.value !== "" &&
!fetchState.value.fetchingError &&
!fetchState.value.isFinished &&
resultCount.value > 0
)
const canLoadMore = computed(() => {
return Boolean(
searchStarted.value &&
!fetchState.value.fetchingError &&
!fetchState.value.isFinished &&
resultCount.value > 0
)
})
const reachResultEndEventSent = ref(false)
/**
Expand Down
45 changes: 21 additions & 24 deletions frontend/src/components/VMediaInfo/VByLine/VByLine.vue
Expand Up @@ -59,6 +59,7 @@ import {
import { useElementSize, useScroll, watchDebounced } from "@vueuse/core"
import { useI18n } from "~/composables/use-i18n"
import { useSearchStore } from "~/stores/search"
import type { SupportedMediaType } from "~/constants/media"
import VSourceCreatorButton from "~/components/VMediaInfo/VByLine/VSourceCreatorButton.vue"
Expand Down Expand Up @@ -95,7 +96,9 @@ export default defineComponent({
const buttonsRef = ref<HTMLElement | null>(null)
const showCreator = computed(() => {
return props.creator && props.creator.toLowerCase() !== "unidentified"
return Boolean(
props.creator && props.creator.toLowerCase() !== "unidentified"
)
})
const i18n = useI18n()
Expand Down Expand Up @@ -213,33 +216,27 @@ export default defineComponent({
{ debounce: 100 }
)
// TODO: implement this function in the search store.
const getCollectionPath = ({
type,
source,
creator,
}: {
type: SupportedMediaType
source: string
creator?: string
}) => {
let path = `/${type}/source/${source}/`
if (creator) path += `creator/${encodeURIComponent(creator)}/`
return path
}
const searchStore = useSearchStore()
const creatorHref = computed(() => {
return showCreator.value
? getCollectionPath({
type: props.mediaType,
source: props.sourceSlug,
creator: props.creator,
})
: undefined
if (!props.creator) return undefined
return searchStore.getCollectionPath({
type: props.mediaType,
collectionParams: {
collection: "creator",
source: props.sourceSlug,
creator: props.creator,
},
})
})
const sourceHref = computed(() => {
return getCollectionPath({
return searchStore.getCollectionPath({
type: props.mediaType,
source: props.sourceSlug,
collectionParams: {
collection: "source",
source: props.sourceSlug,
},
})
})
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/data/api-service.ts
Expand Up @@ -63,6 +63,7 @@ export interface ApiService {
client: AxiosInstance
query<T = unknown>(
resource: string,
slug: string,
params: Record<string, string>
): Promise<AxiosResponse<T>>
get<T = unknown>(
Expand Down Expand Up @@ -138,19 +139,22 @@ export const createApiService = ({

/**
* @param resource - The endpoint of the resource
* @param slug - the optional additional endpoint, used for collections.
* @param params - Url parameter object
* @returns response The API response object
*/
query<T = unknown>(
resource: string,
params: Record<string, string>
slug: string = "",
params: Record<string, string> = {}
): Promise<AxiosResponse<T>> {
return client.get(`${getResourceSlug(resource)}`, { params })
return client.get(`${getResourceSlug(resource)}${slug}`, { params })
},

/**
* @param resource - The endpoint of the resource
* @param slug - The sub-endpoint of the resource
* @param params - Url query parameter object
* @returns Response The API response object
*/
get<T = unknown>(
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/data/media-service.ts
@@ -1,5 +1,8 @@
import { decodeMediaData } from "~/utils/decode-media-data"
import type { PaginatedSearchQuery } from "~/types/search"
import type {
PaginatedCollectionQuery,
PaginatedSearchQuery,
} from "~/types/search"
import type { ApiService } from "~/data/api-service"
import type { DetailFromMediaType, Media } from "~/types/media"
import { AUDIO, type SupportedMediaType } from "~/constants/media"
Expand Down Expand Up @@ -45,9 +48,11 @@ class MediaService<T extends Media> {
/**
* Search for media items by keyword.
* @param params - API search query parameters
* @param slug - optional slug to get a collection
*/
async search(
params: PaginatedSearchQuery
params: PaginatedSearchQuery | PaginatedCollectionQuery,
slug: string = ""
): Promise<MediaResult<Record<string, Media>>> {
// Add the `peaks` param to all audio searches automatically
if (this.mediaType === AUDIO) {
Expand All @@ -56,6 +61,7 @@ class MediaService<T extends Media> {

const res = await this.apiService.query<MediaResult<T[]>>(
this.mediaType,
slug,
params as unknown as Record<string, string>
)
return this.transformResults(res.data)
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/locales/scripts/en.json5
Expand Up @@ -774,6 +774,20 @@
source: "Open source site",
creator: "Open creator page",
},
ariaLabel: {
creator: {
audio: "Audio files by {creator} in {source}",
image: "Images by {creator} in {source}",
},
source: {
audio: "Audio files from {source}",
image: "Images from {source}",
},
tag: {
audio: "Audio files with the tag {tag}",
image: "Images with the tag {tag}",
},
},
resultCountLabel: {
creator: {
audio: {
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/middleware/collection.ts
@@ -0,0 +1,15 @@
import { useFeatureFlagStore } from "~/stores/feature-flag"

import type { Middleware } from "@nuxt/types"

export const collectionMiddleware: Middleware = async ({
$pinia,
error: nuxtError,
}) => {
if (!useFeatureFlagStore($pinia).isOn("additional_search_views")) {
nuxtError({
statusCode: 404,
message: "Additional search views are not enabled",
})
}
}
18 changes: 12 additions & 6 deletions frontend/src/middleware/single-result.ts
Expand Up @@ -7,6 +7,10 @@ import { AUDIO, IMAGE } from "~/constants/media"

import type { Middleware } from "@nuxt/types"

const isSearchPath = (path: string) => path.includes("/search/")
const isSearchOrCollectionPath = (path: string) =>
isSearchPath(path) || path.includes("/source/") || path.includes("/tag/")

export const singleResultMiddleware: Middleware = async ({
route,
from,
Expand All @@ -31,16 +35,18 @@ export const singleResultMiddleware: Middleware = async ({
// Client-side rendering
singleResultStore.setMediaById(mediaType, route.params.id)

if (from && from.path.includes("/search/")) {
if (from && isSearchOrCollectionPath(from.path)) {
const searchStore = useSearchStore($pinia)
searchStore.setBackToSearchPath(from.fullPath)

const searchTerm = Array.isArray(route.query.q)
? route.query.q[0]
: route.query.q
if (isSearchPath(from.path)) {
const searchTerm = Array.isArray(route.query.q)
? route.query.q[0]
: route.query.q

if (searchTerm) {
searchStore.setSearchTerm(searchTerm)
if (searchTerm) {
searchStore.setSearchTerm(searchTerm)
}
}
}
}
Expand Down

0 comments on commit 6102985

Please sign in to comment.