]> source.dussan.org Git - nextcloud-server.git/commitdiff
refactor(app-store): Split app discover section
authorFerdinand Thiessen <opensource@fthiessen.de>
Wed, 23 Oct 2024 10:47:37 +0000 (12:47 +0200)
committerFerdinand Thiessen <opensource@fthiessen.de>
Wed, 23 Oct 2024 11:01:28 +0000 (13:01 +0200)
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
apps/settings/src/components/AppStore/AppStoreDiscover/AppLink.vue
apps/settings/src/components/AppStore/AppStoreDiscover/AppType.vue
apps/settings/src/components/AppStore/AppStoreDiscover/CarouselType.vue
apps/settings/src/components/AppStore/AppStoreDiscover/PostType.vue
apps/settings/src/components/AppStore/AppStoreDiscover/common.ts
apps/settings/src/router/routes.ts
apps/settings/src/views/AppStore/AppStoreSectionDiscover.vue [new file with mode: 0644]

index 8f180f142475ee44e8c67150ca63299c75f93102..750559bb5e26624304889f4b080ce52a853c2788 100644 (file)
@@ -6,93 +6,72 @@
        <a v-if="linkProps" v-bind="linkProps">
                <slot />
        </a>
-       <RouterLink v-else-if="routerProps" v-bind="routerProps">
+       <RouterLink v-else v-bind="routerProps">
                <slot />
        </RouterLink>
 </template>
 
-<script lang="ts">
-import type { RouterLinkProps } from 'vue-router/types/router.js'
+<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
+ * @module AppLink
+ */
+import type { INavigationEntry } from '../../../../../../core/src/types/navigation'
 
 import { loadState } from '@nextcloud/initial-state'
 import { generateUrl } from '@nextcloud/router'
-import { defineComponent } from 'vue'
+import { computed } from 'vue'
 import { RouterLink } from 'vue-router'
-import type { INavigationEntry } from '../../../../../../core/src/types/navigation'
+
+const props = defineProps<{ href: string }>()
 
 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
+ * Properties for the anchor element.
+ * If the app is installed we can open its route so we try to find a matching route.
  */
-export default defineComponent({
-       name: 'AppLink',
-
-       components: { RouterLink },
-
-       props: {
-               href: {
-                       type: String,
-                       required: true,
-               },
-       },
-
-       data() {
+const linkProps = computed(() => {
+       const match = props.href.match(/^app:\/\/([^/]+)(\/.+)?$/)
+       if (match === null) {
+               // not an app URL so it is a link
                return {
-                       routerProps: undefined as RouterLinkProps|undefined,
-                       linkProps: undefined as Record<string, string>|undefined,
+                       href: props.href,
+                       target: '_blank',
+                       rel: 'noreferrer noopener',
                }
-       },
+       }
 
-       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
-                               }
+       // check if an app internal path was requested
+       if (match[2]) {
+               // we do no know anything about app internal path so we only allow generic app paths
+               return {
+                       href: generateUrl(`/apps/${match[1]}${match[2]}`),
+               }
+       }
+       // If we know any route for that app we open it
+       if (match[1] in knownRoutes) {
+               return {
+                       href: knownRoutes[match[1]],
+               }
+       }
+       // nothing found fall back to just open the app store entry
+       return undefined
+})
 
-                               // Fallback to show the app store entry
-                               this.routerProps = {
-                                       to: {
-                                               name: 'apps-details',
-                                               params: {
-                                                       category: this.$route.params?.category ?? 'discover',
-                                                       id: appId,
-                                               },
-                                       },
-                               }
+/**
+ * Fallback to show the app store entry
+ */
+const routerProps = computed(() => {
+       const match = props.href.match(/^app:\/\/([^/]+)(\/.+)?$/)
+       return {
+               to: {
+                       name: 'discover',
+                       params: {
+                               appId: match![1],
                        },
                },
-       },
+       }
 })
 </script>
index a1cba13c76bb02fc81073144bc1a93e6c799c58a..1ddb9b2db43cbbf0617a3eac51f239a3ba089b3e 100644 (file)
 </template>
 
 <script setup lang="ts">
-import type { IAppDiscoverApp } from '../../../constants/AppDiscoverTypes'
+import type { IAppDiscoverApp } from '../../../constants/AppStoreDiscoverTypes.ts'
 
 import { computed } from 'vue'
-import { useAppsStore } from '../../../store/apps-store.ts'
+import { useAppStore } from '../../../store/appStore.ts'
 
 import AppItem from '../AppItem/AppItem.vue'
 
@@ -35,7 +35,7 @@ const props = defineProps<{
        modelValue: IAppDiscoverApp
 }>()
 
-const store = useAppsStore()
+const store = useAppStore()
 const app = computed(() => store.getAppById(props.modelValue.appId))
 
 const appStoreLink = computed(() => props.modelValue.appId ? `https://apps.nextcloud.com/apps/${props.modelValue.appId}` : '#')
@@ -47,7 +47,7 @@ const appStoreLink = computed(() => props.modelValue.appId ? `https://apps.nextc
 
        &:hover {
                background: var(--color-background-hover);
-               border-radius: var(--border-radius-rounded);
+               border-radius: var(--border-radius-container);
        }
 
        &__skeleton {
index 125aedda61cfe3e0ddf76ea7c261b2a7653dda71..b629a2217615cd7f2f865e414c654d8c85dba521 100644 (file)
@@ -61,7 +61,7 @@
 
 <script lang="ts">
 import type { PropType } from 'vue'
-import type { IAppDiscoverCarousel } from '../../../constants/AppDiscoverTypes.ts'
+import type { IAppDiscoverCarousel } from '../../../constants/AppStoreDiscoverTypes.ts'
 
 import { mdiChevronLeft, mdiChevronRight, mdiCircleOutline, mdiCircleSlice8 } from '@mdi/js'
 import { translate as t } from '@nextcloud/l10n'
index 7703ef3d62614f4e1608e28437bbd65fee7297c8..11335a5e06365e32e51e044d1ef6a7abe5981d50 100644 (file)
@@ -55,7 +55,7 @@
 </template>
 
 <script lang="ts">
-import type { IAppDiscoverPost } from '../../../constants/AppDiscoverTypes.ts'
+import type { IAppDiscoverPost } from '../../../constants/AppStoreDiscoverTypes'
 import type { PropType } from 'vue'
 
 import { mdiPlayCircleOutline } from '@mdi/js'
@@ -193,7 +193,7 @@ export default defineComponent({
        max-height: 300px;
        width: 100%;
        background-color: var(--color-primary-element-light);
-       border-radius: var(--border-radius-rounded);
+       border-radius: var(--border-radius-container-large);
 
        display: flex;
        flex-direction: row;
@@ -212,7 +212,7 @@ export default defineComponent({
        &__text {
                display: block;
                width: 100%;
-               padding: var(--border-radius-rounded);
+               padding: calc(3 * var(--default-grid-baseline));
                overflow-y: scroll;
        }
 
@@ -226,7 +226,7 @@ export default defineComponent({
                overflow: hidden;
 
                max-width: 450px;
-               border-radius: var(--border-radius-rounded);
+               border-radius: var(--border-radius-container-large);
 
                &--fullwidth {
                        max-width: unset;
@@ -283,13 +283,13 @@ export default defineComponent({
                        min-width: 100%;
 
                        &--end {
-                               border-radius: var(--border-radius-rounded);
+                               border-radius: var(--border-radius-container);
                                border-start-end-radius: 0;
                                border-start-start-radius: 0;
                        }
 
                        &--start {
-                               border-radius: var(--border-radius-rounded);
+                               border-radius: var(--border-radius-container);
                                border-end-end-radius: 0;
                                border-end-start-radius: 0;
                        }
index ef86a56e91066212952cd7631dcf0bd1376a9a7a..b57e2430b23892a8680ef9cbe525804381d392c7 100644 (file)
@@ -3,9 +3,8 @@
  * SPDX-License-Identifier: AGPL-3.0-or-later
  */
 import type { PropType } from 'vue'
-import type { IAppDiscoverElement } from '../../../constants/AppDiscoverTypes.ts'
-
-import { APP_DISCOVER_KNOWN_TYPES } from '../../../constants/AppDiscoverTypes.ts'
+import type { IAppDiscoverElement } from '../../../constants/AppStoreDiscoverTypes'
+import { APP_DISCOVER_KNOWN_TYPES } from '../../../constants/AppStoreDiscoverTypes'
 
 /**
  * Common Props for all app discover types
index 35b3b1306d56e2248b3c9075744925babf712394..62503975f5fad42882f5ede1a50c5bcaa11efd57 100644 (file)
@@ -8,9 +8,11 @@ import { loadState } from '@nextcloud/initial-state'
 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 AppStore = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStore/AppStore.vue')
+const AppStoreAppsSection = () => import(/* webpackChunkName: 'settings-apps-view' */ '../views/AppStore/AppStoreSectionApps.vue')
+const AppStoreDiscoverSection = () => import(/* webpackChunkName: 'settings-apps-view' */ '../views/AppStore/AppStoreSectionDiscover.vue')
+const AppStoreNavigation = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStore/AppStoreNavigation.vue')
+const AppStoreSidebar = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStore/AppStoreSidebar.vue')
 
 const UserManagement = () => import(/* webpackChunkName: 'settings-users' */'../views/UserManagement.vue')
 const UserManagementNavigation = () => import(/* webpackChunkName: 'settings-users' */'../views/UserManagementNavigation.vue')
@@ -35,10 +37,15 @@ const routes: RouteConfig[] = [
                path: '/:index(index.php/)?settings/apps',
                name: 'apps',
                redirect: {
-                       name: 'apps-category',
-                       params: {
-                               category: appstoreEnabled ? 'discover' : 'installed',
-                       },
+                       ...(appstoreEnabled
+                               ? { name: 'discover' }
+                               : {
+                                       name: 'apps-category',
+                                       params: {
+                                               category: 'installed',
+                                       },
+                               }
+                       ),
                },
                components: {
                        default: AppStore,
@@ -46,13 +53,19 @@ const routes: RouteConfig[] = [
                        sidebar: AppStoreSidebar,
                },
                children: [
+                       {
+                               name: 'discover',
+                               path: 'discover/:appId?',
+                               component: AppStoreDiscoverSection,
+                       },
                        {
                                path: ':category',
-                               name: 'apps-category',
+                               name: 'app-category',
+                               component: AppStoreAppsSection,
                                children: [
                                        {
-                                               path: ':id',
-                                               name: 'apps-details',
+                                               path: ':appId',
+                                               name: 'app-details',
                                        },
                                ],
                        },
diff --git a/apps/settings/src/views/AppStore/AppStoreSectionDiscover.vue b/apps/settings/src/views/AppStore/AppStoreSectionDiscover.vue
new file mode 100644 (file)
index 0000000..061e352
--- /dev/null
@@ -0,0 +1,119 @@
+<!--
+  - 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="mdiEyeOff" :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 { IAppDiscoverElements } from '../../constants/AppStoreDiscoverTypes'
+
+import { mdiEyeOff } from '@mdi/js'
+import { showError } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { generateUrl } from '@nextcloud/router'
+import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue'
+
+import axios from '@nextcloud/axios'
+import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+
+import { parseApiResponse, filterElements } from '../../utils/appDiscoverParser.ts'
+import logger from '../../logger'
+
+const PostType = defineAsyncComponent(() => import('../../components/AppStore/AppStoreDiscover/PostType.vue'))
+const CarouselType = defineAsyncComponent(() => import('../../components/AppStore/AppStoreDiscover/CarouselType.vue'))
+const ShowcaseType = defineAsyncComponent(() => import('../../components/AppStore/AppStoreDiscover/ShowcaseType.vue'))
+
+const hasError = ref(false)
+const elements = ref<IAppDiscoverElements[]>([])
+
+/**
+ * Shuffle using the Fisher-Yates algorithm
+ * @param array The array to shuffle (in place)
+ */
+const 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 { data } = await axios.get<Record<string, unknown>[]>(generateUrl('/settings/api/apps/discover'))
+               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'))
+       }
+})
+
+const 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>