refactor(appstore): migrate app discover section to Vue 3

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-12-27 13:56:21 +01:00
parent a524610803
commit ff45fb8ae5
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
15 changed files with 545 additions and 509 deletions

View file

@ -1,13 +1,8 @@
/**
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/**
* Currently known types of app discover section elements
*/
export const APP_DISCOVER_KNOWN_TYPES = ['post', 'showcase', 'carousel'] as const
/**
* Helper for localized values
*/

View file

@ -0,0 +1,77 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import { mdiCogOutline } from '@mdi/js'
import { NcLoadingIcon } from '@nextcloud/vue'
import PQueue from 'p-queue'
import { ref, watchEffect } from 'vue'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
const props = defineProps<{
app: IAppstoreApp | IAppstoreExApp
}>()
const isError = ref(false)
const isLoading = ref(true)
watchEffect(() => {
if (props.app.screenshot) {
isError.value = false
isLoading.value = true
queue.add(() => {
const image = new Image()
const { promise, resolve } = Promise.withResolvers()
image.onload = () => {
isLoading.value = false
resolve()
}
image.onerror = () => {
isError.value = true
isLoading.value = false
resolve()
}
image.src = props.app.screenshot!
return promise
})
} else {
isLoading.value = false
isError.value = false
}
})
</script>
<script lang="ts">
const queue = new PQueue()
</script>
<template>
<div :class="$style.appImage">
<NcIconSvgWrapper
v-if="isError || !props.app.screenshot"
:size="80"
:path="mdiCogOutline" />
<NcLoadingIcon v-else-if="isLoading" :size="80" />
<img :class="$style.appImage__image" :src="props.app.screenshot" alt="">
</div>
</template>
<style module>
.appImage {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.appImage__image {
object-fit: cover;
height: 100%;
width: 100%;
}
</style>

View file

@ -0,0 +1,83 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
/**
* This component either shows a native link to the installed app or external size
* or a router link to the appstore page of the app if not installed
*/
import type { RouterLinkProps } from 'vue-router'
import type { INavigationEntry } from '../../../../core/src/types/navigation.d.ts'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { ref, watchEffect } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const props = defineProps<{
href: string
}>()
const route = useRoute()
const knownRoutes = Object.fromEntries(loadState<INavigationEntry[]>('core', 'apps').map((app) => [app.app ?? app.id, app.href]))
const routerProps = ref<RouterLinkProps>()
const linkProps = ref<Record<string, string>>()
watchEffect(() => {
const match = props.href.match(/^app:(\/\/)?([^/]+)(\/.+)?$/)
routerProps.value = undefined
linkProps.value = undefined
// not an app url
if (match === null) {
linkProps.value = {
href: props.href,
target: '_blank',
rel: 'noreferrer noopener',
}
return
}
const appId = match[2]!
// Check if specific route was requested
if (match[3]) {
// we do no know anything about app internal path so we only allow generic app paths
linkProps.value = {
href: generateUrl(`/apps/${appId}${match[3]}`),
}
return
}
// If we know any route for that app we open it
if (appId in knownRoutes) {
linkProps.value = {
href: knownRoutes[appId]!,
}
return
}
// Fallback to show the app store entry
routerProps.value = {
to: {
name: 'apps-details',
params: {
category: route.params?.category ?? 'discover',
id: appId,
},
},
}
})
</script>
<template>
<a v-if="linkProps" v-bind="linkProps">
<slot />
</a>
<RouterLink v-else-if="routerProps" v-bind="routerProps">
<slot />
</RouterLink>
</template>

View file

@ -53,7 +53,7 @@ export default defineComponent({
computed: {
title() {
const appScore = (this.score * 5).toFixed(1)
return t('settings', 'Community rating: {score}/5', { score: appScore })
return t('appstore', 'Community rating: {score}/5', { score: appScore })
},
fullStars() {

View file

@ -1,98 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<a v-if="linkProps" v-bind="linkProps">
<slot />
</a>
<RouterLink v-else-if="routerProps" v-bind="routerProps">
<slot />
</RouterLink>
</template>
<script lang="ts">
import type { RouterLinkProps } from 'vue-router/types/router.js'
import type { INavigationEntry } from '../../../../../core/src/types/navigation.d.ts'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { defineComponent } from 'vue'
import { RouterLink } from 'vue-router'
const apps = loadState<INavigationEntry[]>('core', 'apps')
const knownRoutes = Object.fromEntries(apps.map((app) => [app.app ?? app.id, app.href]))
/**
* This component either shows a native link to the installed app or external size - or a router link to the appstore page of the app if not installed
*/
export default defineComponent({
name: 'AppLink',
components: { RouterLink },
props: {
href: {
type: String,
required: true,
},
},
data() {
return {
routerProps: undefined as RouterLinkProps | undefined,
linkProps: undefined as Record<string, string> | undefined,
}
},
watch: {
href: {
immediate: true,
handler() {
const match = this.href.match(/^app:\/\/([^/]+)(\/.+)?$/)
this.routerProps = undefined
this.linkProps = undefined
// not an app url
if (match === null) {
this.linkProps = {
href: this.href,
target: '_blank',
rel: 'noreferrer noopener',
}
return
}
const appId = match[1]
// Check if specific route was requested
if (match[2]) {
// we do no know anything about app internal path so we only allow generic app paths
this.linkProps = {
href: generateUrl(`/apps/${appId}${match[2]}`),
}
return
}
// If we know any route for that app we open it
if (appId in knownRoutes) {
this.linkProps = {
href: knownRoutes[appId],
}
return
}
// Fallback to show the app store entry
this.routerProps = {
to: {
name: 'apps-details',
params: {
category: this.$route.params?.category ?? 'discover',
id: appId,
},
},
}
},
},
},
})
</script>

View file

@ -1,126 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="app-discover">
<NcEmptyContent
v-if="hasError"
:name="t('settings', 'Nothing to show')"
:description="t('settings', 'Could not load section content from app store.')">
<template #icon>
<NcIconSvgWrapper :path="mdiEyeOffOutline" :size="64" />
</template>
</NcEmptyContent>
<NcEmptyContent
v-else-if="elements.length === 0"
:name="t('settings', 'Loading')"
:description="t('settings', 'Fetching the latest news…')">
<template #icon>
<NcLoadingIcon :size="64" />
</template>
</NcEmptyContent>
<template v-else>
<component
:is="getComponent(entry.type)"
v-for="entry, index in elements"
:key="entry.id ?? index"
v-bind="entry" />
</template>
</div>
</template>
<script setup lang="ts">
import type { OCSResponse } from '@nextcloud/typings/ocs'
import type { IAppDiscoverElements } from '../../constants/AppDiscoverTypes.ts'
import { mdiEyeOffOutline } from '@mdi/js'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { filterElements, parseApiResponse } from '../../utils/appDiscoverParser.ts'
import logger from '../../utils/logger.ts'
const PostType = defineAsyncComponent(() => import('./PostType.vue'))
const CarouselType = defineAsyncComponent(() => import('./CarouselType.vue'))
const ShowcaseType = defineAsyncComponent(() => import('./ShowcaseType.vue'))
const hasError = ref(false)
const elements = ref<IAppDiscoverElements[]>([])
/**
* Shuffle using the Fisher-Yates algorithm
*
* @param array The array to shuffle (in place)
*/
function shuffleArray<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]]
}
return array
}
/**
* Load the app discover section information
*/
onBeforeMount(async () => {
try {
const response = await axios.get<OCSResponse<Record<string, unknown>[]>>(generateOcsUrl('/apps/appstore/api/v1/discover'))
const { data } = response.data.ocs
if (data.length === 0) {
logger.info('No app discover elements available (empty response)')
hasError.value = true
return
}
// Parse data to ensure dates are useable and then filter out expired or future elements
const parsedElements = data.map(parseApiResponse).filter(filterElements)
// Shuffle elements to make it looks more interesting
const shuffledElements = shuffleArray(parsedElements)
// Sort pinned elements first
shuffledElements.sort((a, b) => (a.order ?? Infinity) < (b.order ?? Infinity) ? -1 : 1)
// Set the elements to the UI
elements.value = shuffledElements
} catch (error) {
hasError.value = true
logger.error(error as Error)
showError(t('settings', 'Could not load app discover section'))
}
})
/**
* @param {string} type
*/
function getComponent(type) {
if (type === 'post') {
return PostType
} else if (type === 'carousel') {
return CarouselType
} else if (type === 'showcase') {
return ShowcaseType
}
return defineComponent({
mounted: () => logger.error('Unknown component requested ', type),
render: (h) => h('div', t('settings', 'Could not render element')),
})
}
</script>
<style scoped lang="scss">
.app-discover {
max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */
margin-inline: auto;
padding-inline: 54px;
/* Padding required to make last element not bound to the bottom */
padding-block-end: var(--default-clickable-area, 44px);
display: flex;
flex-direction: column;
gap: var(--default-clickable-area, 44px);
}
</style>

View file

@ -3,15 +3,8 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<AppItem
v-if="app"
:app="app"
category="discover"
class="app-discover-app"
inline
:list-view="false" />
<a
v-else
v-if="!app"
class="app-discover-app app-discover-app__skeleton"
:href="appStoreLink"
target="_blank"
@ -24,14 +17,32 @@
<span class="skeleton-element" />
<span class="skeleton-element" />
</a>
<article v-else class="app-discover-app">
<AppImage class="app-discover-app__image" :app="app" />
<div class="app-discover-app__wrapper">
<h3 class="app-discover-app__name">
<AppLink :href="`app:${app.id}`">
{{ app.name }}
</AppLink>
</h3>
<p>{{ app.summary }}</p>
<AppScore
v-if="app.ratingNumThresholdReached"
class="app-discover-app__score"
:score="app.score" />
</div>
</article>
</template>
<script setup lang="ts">
import type { IAppDiscoverApp } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverApp } from '../../apps-discover.d.ts'
import { computed } from 'vue'
import AppItem from '../AppList/AppItem.vue'
import { useAppsStore } from '../../store/apps-store.ts'
import AppImage from '../AppImage.vue'
import AppLink from '../AppLink.vue'
import AppScore from '../AppScore.vue'
import { useAppsStore } from '../../store/apps.ts'
const props = defineProps<{
modelValue: IAppDiscoverApp
@ -40,16 +51,42 @@ const props = defineProps<{
const store = useAppsStore()
const app = computed(() => store.getAppById(props.modelValue.appId))
const appStoreLink = computed(() => props.modelValue.appId ? `https://apps.nextcloud.com/apps/${props.modelValue.appId}` : '#')
const appStoreLink = computed(() => props.modelValue.appId
? `https://apps.nextcloud.com/apps/${props.modelValue.appId}`
: '#')
</script>
<style scoped lang="scss">
.app-discover-app {
width: 100% !important; // full with of the showcase item
border-radius: var(--border-radius-element);
display: flex;
flex-direction: column;
overflow: hidden;
width: 100% !important;
&:hover {
background: var(--color-background-hover);
border-radius: var(--border-radius-rounded);
}
&__image {
height: 96px;
width: 100%;
}
&__name {
margin-block: 0.5rem;
font-size: 1.2rem;
}
&__score {
margin-top: auto;
}
&__wrapper {
display: flex;
flex-direction: column;
padding: calc(2 * var(--default-grid-baseline));
padding-top: 0px;
}
&__skeleton {

View file

@ -3,7 +3,7 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section :aria-roledescription="t('settings', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
<section :aria-roledescription="t('appstore', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
<h3 v-if="headline" :id="headingId">
{{ translatedHeadline }}
</h3>
@ -12,7 +12,7 @@
<NcButton
class="app-discover-carousel__button app-discover-carousel__button--previous"
variant="tertiary-no-background"
:aria-label="t('settings', 'Previous slide')"
:aria-label="t('appstore', 'Previous slide')"
:disabled="!hasPrevious"
@click="currentIndex -= 1">
<template #icon>
@ -22,11 +22,11 @@
</div>
<Transition :name="transitionName" mode="out-in">
<PostType
<DiscoverTypePost
v-bind="shownElement"
:key="shownElement.id ?? currentIndex"
:aria-labelledby="`${internalId}-tab-${currentIndex}`"
:dom-id="`${internalId}-tabpanel-${currentIndex}`"
:domId="`${internalId}-tabpanel-${currentIndex}`"
inline
role="tabpanel" />
</Transition>
@ -35,7 +35,7 @@
<NcButton
class="app-discover-carousel__button app-discover-carousel__button--next"
variant="tertiary-no-background"
:aria-label="t('settings', 'Next slide')"
:aria-label="t('appstore', 'Next slide')"
:disabled="!hasNext"
@click="currentIndex += 1">
<template #icon>
@ -44,12 +44,12 @@
</NcButton>
</div>
</div>
<div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('settings', 'Choose slide to display')">
<div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('appstore', 'Choose slide to display')">
<NcButton
v-for="index of content.length"
:id="`${internalId}-tab-${index}`"
:key="index"
:aria-label="t('settings', '{index} of {total}', { index, total: content.length })"
:aria-label="t('appstore', '{index} of {total}', { index, total: content.length })"
:aria-controls="`${internalId}-tabpanel-${index}`"
:aria-selected="`${currentIndex === (index - 1)}`"
role="tab"
@ -63,85 +63,53 @@
</section>
</template>
<script lang="ts">
<script setup lang="ts">
import type { PropType } from 'vue'
import type { IAppDiscoverCarousel } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverCarousel } from '../../apps-discover.d.ts'
import { mdiChevronLeft, mdiChevronRight, mdiCircleOutline, mdiCircleSlice8 } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { computed, defineComponent, nextTick, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import { computed, nextTick, ref, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import PostType from './PostType.vue'
import DiscoverTypePost from './DiscoverTypePost.vue'
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
import { commonAppDiscoverProps } from './common.ts'
export default defineComponent({
name: 'CarouselType',
const props = defineProps({
...commonAppDiscoverProps,
components: {
NcButton,
NcIconSvgWrapper,
PostType,
/**
* The content of the carousel
*/
content: {
type: Array as PropType<IAppDiscoverCarousel['content']>,
required: true,
},
})
props: {
...commonAppDiscoverProps,
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
/**
* The content of the carousel
*/
content: {
type: Array as PropType<IAppDiscoverCarousel['content']>,
required: true,
},
},
const currentIndex = ref(Math.min(1, props.content.length - 1))
const shownElement = ref(props.content[currentIndex.value]!)
const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
const hasPrevious = computed(() => currentIndex.value > 0)
setup(props) {
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
const headingId = computed(() => `${internalId.value}-h`)
const currentIndex = ref(Math.min(1, props.content.length - 1))
const shownElement = ref(props.content[currentIndex.value])
const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
const hasPrevious = computed(() => currentIndex.value > 0)
const transitionName = ref('slide-in')
watch(() => currentIndex.value, (o, n) => {
if (o < n) {
transitionName.value = 'slide-in'
} else {
transitionName.value = 'slide-out'
}
const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
const headingId = computed(() => `${internalId.value}-h`)
const transitionName = ref('slide-in')
watch(() => currentIndex.value, (o, n) => {
if (o < n) {
transitionName.value = 'slide-in'
} else {
transitionName.value = 'slide-out'
}
// Wait next tick
nextTick(() => {
shownElement.value = props.content[currentIndex.value]
})
})
return {
t,
internalId,
headingId,
hasNext,
hasPrevious,
currentIndex,
shownElement,
transitionName,
translatedHeadline,
mdiChevronLeft,
mdiChevronRight,
mdiCircleOutline,
mdiCircleSlice8,
}
},
// Wait next tick
nextTick(() => {
shownElement.value = props.content[currentIndex.value]!
})
})
</script>

View file

@ -47,7 +47,7 @@
:type="source.mime">
<img
v-if="isImage"
:src="generatePrivacyUrl(mediaSources[0].src)"
:src="generatePrivacyUrl(mediaSources[0]!.src)"
:alt="mediaAlt">
</component>
<div class="app-discover-post__play-icon-wrapper">
@ -61,139 +61,109 @@
</article>
</template>
<script lang="ts">
<script setup lang="ts">
import type { PropType } from 'vue'
import type { IAppDiscoverPost } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverPost } from '../../apps-discover.d.ts'
import { mdiPlayCircleOutline } from '@mdi/js'
import { generateOcsUrl } from '@nextcloud/router'
import { useElementSize, useElementVisibility } from '@vueuse/core'
import { computed, defineComponent, ref, watchEffect } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import AppLink from './AppLink.vue'
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
import { commonAppDiscoverProps } from './common.ts'
export default defineComponent({
components: {
AppLink,
NcIconSvgWrapper,
const props = defineProps({
...commonAppDiscoverProps,
text: {
type: Object as PropType<IAppDiscoverPost['text']>,
required: false,
default: () => null,
},
props: {
...commonAppDiscoverProps,
text: {
type: Object as PropType<IAppDiscoverPost['text']>,
required: false,
default: () => null,
},
media: {
type: Object as PropType<IAppDiscoverPost['media']>,
required: false,
default: () => null,
},
inline: {
type: Boolean,
required: false,
default: false,
},
domId: {
type: String,
required: false,
default: null,
},
media: {
type: Object as PropType<IAppDiscoverPost['media']>,
required: false,
default: () => null,
},
setup(props) {
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
const translatedText = useLocalizedValue(computed(() => props.text))
const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
inline: {
type: Boolean,
required: false,
default: false,
},
const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
domId: {
type: String,
required: false,
default: null,
},
})
const isImage = computed(() => mediaSources?.value?.[0].mime.startsWith('image/') === true)
/**
* Is the media is shown full width
*/
const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
const translatedText = useLocalizedValue(computed(() => props.text))
const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
/**
* Link on the media
* Fallback to post link to prevent link inside link (which is invalid HTML)
*/
const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
const hasPlaybackEnded = ref(false)
const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
const isImage = computed(() => mediaSources.value?.[0]?.mime.startsWith('image/') === true)
/**
* Is the media is shown full width
*/
const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
/**
* The content is sized / styles are applied based on the container width
* To make it responsive even for inline usage and when opening / closing the sidebar / navigation
*/
const container = ref<HTMLElement>()
const { width: containerWidth } = useElementSize(container)
const isSmallWidth = computed(() => containerWidth.value < 600)
/**
* Link on the media
* Fallback to post link to prevent link inside link (which is invalid HTML)
*/
const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
/**
* Generate URL for cached media to prevent user can be tracked
*
* @param url The URL to resolve
*/
const generatePrivacyUrl = (url: string) => url.startsWith('/')
? url
: generateOcsUrl('/apps/appstore/api/v1/discover/media?fileName={fileName}', { fileName: url })
const hasPlaybackEnded = ref(false)
const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
const mediaElement = ref<HTMLVideoElement | HTMLPictureElement>()
const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
watchEffect(() => {
// Only if media is video
if (!isImage.value && mediaElement.value) {
const video = mediaElement.value as HTMLVideoElement
/**
* The content is sized / styles are applied based on the container width
* To make it responsive even for inline usage and when opening / closing the sidebar / navigation
*/
const container = ref<HTMLElement>()
const { width: containerWidth } = useElementSize(container)
const isSmallWidth = computed(() => containerWidth.value < 600)
if (mediaIsVisible.value) {
// Ensure video is muted - otherwise .play() will be blocked by browsers
video.muted = true
// If visible start playback
video.play()
} else {
// If not visible pause the playback
video.pause()
// If the animation has ended reset
if (video.ended) {
video.currentTime = 0
hasPlaybackEnded.value = false
}
}
/**
* Generate URL for cached media to prevent user can be tracked
*
* @param url The URL to resolve
*/
function generatePrivacyUrl(url: string) {
return url.startsWith('/')
? url
: generateOcsUrl('/apps/appstore/api/v1/discover/media?fileName={fileName}', { fileName: url })
}
const mediaElement = ref<HTMLVideoElement | HTMLPictureElement>()
const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
watchEffect(() => {
// Only if media is video
if (!isImage.value && mediaElement.value) {
const video = mediaElement.value as HTMLVideoElement
if (mediaIsVisible.value) {
// Ensure video is muted - otherwise .play() will be blocked by browsers
video.muted = true
// If visible start playback
video.play()
} else {
// If not visible pause the playback
video.pause()
// If the animation has ended reset
if (video.ended) {
video.currentTime = 0
hasPlaybackEnded.value = false
}
})
return {
mdiPlayCircleOutline,
container,
translatedText,
translatedHeadline,
mediaElement,
mediaSources,
mediaAlt,
mediaLink,
hasPlaybackEnded,
showPlayVideo,
isFullWidth,
isSmallWidth,
isImage,
generatePrivacyUrl,
}
},
}
})
</script>

View file

@ -16,71 +16,50 @@
<ul class="app-discover-showcase__list">
<li
v-for="(item, index) of content"
:key="item.id ?? index"
:key="'id' in item ? item.id : index"
class="app-discover-showcase__item">
<PostType
<DiscoverTypePost
v-if="item.type === 'post'"
v-bind="item"
inline />
<AppType v-else-if="item.type === 'app'" :model-value="item" />
<DiscoverTypeApp v-else-if="item.type === 'app'" :modelValue="item" />
</li>
</ul>
</section>
</template>
<script lang="ts">
<script setup lang="ts">
import type { PropType } from 'vue'
import type { IAppDiscoverShowcase } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverShowcase } from '../../apps-discover.d.ts'
import { translate as t } from '@nextcloud/l10n'
import { useElementSize } from '@vueuse/core'
import { computed, defineComponent, ref } from 'vue'
import AppType from './AppType.vue'
import PostType from './PostType.vue'
import { computed, ref } from 'vue'
import DiscoverTypeApp from './DiscoverTypeApp.vue'
import DiscoverTypePost from './DiscoverTypePost.vue'
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
import { commonAppDiscoverProps } from './common.ts'
export default defineComponent({
name: 'ShowcaseType',
const props = defineProps({
...commonAppDiscoverProps,
components: {
AppType,
PostType,
},
props: {
...commonAppDiscoverProps,
/**
* The content of the carousel
*/
content: {
type: Array as PropType<IAppDiscoverShowcase['content']>,
required: true,
},
},
setup(props) {
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
/**
* Make the element responsive based on the container width to also handle open navigation or sidebar
*/
const container = ref<HTMLElement>()
const { width: containerWidth } = useElementSize(container)
const isSmallWidth = computed(() => containerWidth.value < 768)
const isExtraSmallWidth = computed(() => containerWidth.value < 512)
return {
t,
container,
isSmallWidth,
isExtraSmallWidth,
translatedHeadline,
}
/**
* The content of the carousel
*/
content: {
type: Array as PropType<IAppDiscoverShowcase['content']>,
required: true,
},
})
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
/**
* Make the element responsive based on the container width to also handle open navigation or sidebar
*/
const container = ref<HTMLElement>()
const { width: containerWidth } = useElementSize(container)
const isSmallWidth = computed(() => containerWidth.value < 768)
const isExtraSmallWidth = computed(() => containerWidth.value < 512)
</script>
<style scoped lang="scss">

View file

@ -3,9 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { PropType } from 'vue'
import type { IAppDiscoverElement } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverElement } from '../../apps-discover.d.ts'
import { APP_DISCOVER_KNOWN_TYPES } from '../../constants/AppDiscoverTypes.ts'
import { APP_DISCOVER_KNOWN_TYPES } from '../../constants.ts'
/**
* Common Props for all app discover types

View file

@ -1,23 +1,13 @@
/**
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ILocalizedValue } from '../constants/AppDiscoverTypes.ts'
import type { Ref } from 'vue'
import type { ILocalizedValue } from '../apps-discover.d.ts'
import { getLanguage } from '@nextcloud/l10n'
import {
type Ref,
computed,
} from 'vue'
/**
* Helper to get the localized value for the current users language
*
* @param dict The dictionary to get the value from
* @param language The language to use
*/
const getLocalizedValue = <T>(dict: ILocalizedValue<T>, language: string) => dict[language] ?? dict[language.split('_')[0]] ?? dict.en ?? null
import { computed } from 'vue'
/**
* Get the localized value of the dictionary provided
@ -33,3 +23,13 @@ export function useLocalizedValue<T>(dict: Ref<ILocalizedValue<T | undefined> |
return computed(() => !dict?.value ? null : getLocalizedValue<T>(dict.value as ILocalizedValue<T>, language))
}
/**
* Helper to get the localized value for the current users language
*
* @param dict The dictionary to get the value from
* @param language The language to use
*/
function getLocalizedValue<T>(dict: ILocalizedValue<T>, language: string) {
return dict[language] ?? dict[language.split('_')[0]!] ?? dict.en ?? null
}

View file

@ -1,34 +1,35 @@
/**
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { RouteConfig } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { loadState } from '@nextcloud/initial-state'
import { defineAsyncComponent } from 'vue'
const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
// Dynamic loading
const AppStore = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStore.vue')
const AppStoreNavigation = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStoreNavigation.vue')
const AppStoreSidebar = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStoreSidebar.vue')
const AppstoreDiscover = defineAsyncComponent(() => import('../views/AppstoreDiscover.vue'))
const routes: RouteConfig[] = [
const routes: RouteRecordRaw[] = [
{
path: '/:index(index.php/)?settings/apps',
name: 'apps',
redirect: {
name: 'apps-category',
params: {
category: appstoreEnabled ? 'discover' : 'installed',
},
},
components: {
default: AppStore,
navigation: AppStoreNavigation,
sidebar: AppStoreSidebar,
},
redirect: appstoreEnabled
? {
name: 'apps-discover',
}
: {
name: 'apps-category',
params: { category: 'installed' },
},
children: [
{
path: 'discover/:id?',
name: 'apps-discover',
component: AppstoreDiscover,
},
{
path: 'apps/:category',
name: 'apps-category',

View file

@ -0,0 +1,52 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { OCSResponse } from '@nextcloud/typings/ocs'
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { filterElements, parseApiResponse } from '../utils/appDiscoverParser.ts'
/**
* Get app discover elements
*/
export async function getDiscoverElements() {
const data = await loadDiscoverElements()
if (data.length === 0) {
throw new Error('No app discover elements available (empty response)')
}
// Parse data to ensure dates are useable and then filter out expired or future elements
const parsedElements = data.map(parseApiResponse)
.filter(filterElements)
// Shuffle elements to make it looks more interesting
const shuffledElements = shuffleArray(parsedElements)
// Sort pinned elements first
shuffledElements.sort((a, b) => (a.order ?? Infinity) < (b.order ?? Infinity) ? -1 : 1)
return shuffledElements
}
/**
* Shuffle using the Fisher-Yates algorithm
*
* @param array The array to shuffle (in place)
*/
function shuffleArray<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j]!, array[i]!]
}
return array
}
/**
* Load discover elements from the API
*/
async function loadDiscoverElements() {
const response = await axios.get<OCSResponse<Record<string, unknown>[]>>(generateOcsUrl('/apps/appstore/api/v1/discover'))
const { data } = response.data.ocs
return data
}

View file

@ -0,0 +1,98 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppDiscoverElements } from '../apps-discover.d.ts'
import { mdiEyeOffOutline } from '@mdi/js'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { getDiscoverElements } from '../service/app-discover.ts'
import logger from '../utils/logger.ts'
const PostType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypePost.vue'))
const CarouselType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypeCarousel.vue'))
const ShowcaseType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypeShowcase.vue'))
const hasError = ref(false)
const elements = ref<IAppDiscoverElements[]>([])
/**
* Load the app discover section information
*/
onBeforeMount(async () => {
try {
// Set the elements to the UI
elements.value = await getDiscoverElements()
} catch (error) {
hasError.value = true
logger.error(error as Error)
showError(t('appstore', 'Could not load app discover section'))
}
})
/**
* Get the component for the given type
*
* @param type - The type of the component
*/
function getComponent(type: IAppDiscoverElements['type']) {
if (type === 'post') {
return PostType
} else if (type === 'carousel') {
return CarouselType
} else if (type === 'showcase') {
return ShowcaseType
}
return defineComponent({
mounted: () => logger.error('Unknown component requested ', type),
render: (h) => h('div', t('appstore', 'Could not render element')),
})
}
</script>
<template>
<NcEmptyContent
v-if="hasError"
:name="t('appstore', 'Nothing to show')"
:description="t('appstore', 'Could not load section content from app store.')">
<template #icon>
<NcIconSvgWrapper :path="mdiEyeOffOutline" :size="64" />
</template>
</NcEmptyContent>
<NcEmptyContent
v-else-if="elements.length === 0"
:name="t('appstore', 'Loading')"
:description="t('appstore', 'Fetching the latest news…')">
<template #icon>
<NcLoadingIcon :size="64" />
</template>
</NcEmptyContent>
<div v-else class="app-discover">
<component
:is="getComponent(entry.type)"
v-for="entry, index in elements"
:key="entry.id ?? index"
v-bind="entry" />
</div>
</template>
<style scoped lang="scss">
.app-discover {
max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */
margin-inline: auto;
padding-inline: 54px;
/* Padding required to make last element not bound to the bottom */
padding-block-end: var(--default-clickable-area, 44px);
display: flex;
flex-direction: column;
gap: var(--default-clickable-area, 44px);
}
</style>