mirror of
https://github.com/nextcloud/server.git
synced 2026-05-22 10:06:37 -04:00
refactor(appstore): migrate app discover section to Vue 3
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
a524610803
commit
ff45fb8ae5
15 changed files with 545 additions and 509 deletions
|
|
@ -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
|
||||
*/
|
||||
77
apps/appstore/src/components/AppImage.vue
Normal file
77
apps/appstore/src/components/AppImage.vue
Normal 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>
|
||||
83
apps/appstore/src/components/AppLink.vue
Normal file
83
apps/appstore/src/components/AppLink.vue
Normal 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>
|
||||
|
|
@ -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() {
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
52
apps/appstore/src/service/app-discover.ts
Normal file
52
apps/appstore/src/service/app-discover.ts
Normal 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
|
||||
}
|
||||
98
apps/appstore/src/views/AppstoreDiscover.vue
Normal file
98
apps/appstore/src/views/AppstoreDiscover.vue
Normal 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>
|
||||
Loading…
Reference in a new issue