<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>
</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'
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}` : '#')
&:hover {
background: var(--color-background-hover);
- border-radius: var(--border-radius-rounded);
+ border-radius: var(--border-radius-container);
}
&__skeleton {
<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'
</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'
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;
&__text {
display: block;
width: 100%;
- padding: var(--border-radius-rounded);
+ padding: calc(3 * var(--default-grid-baseline));
overflow-y: scroll;
}
overflow: hidden;
max-width: 450px;
- border-radius: var(--border-radius-rounded);
+ border-radius: var(--border-radius-container-large);
&--fullwidth {
max-width: unset;
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;
}
* 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
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')
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,
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',
},
],
},
--- /dev/null
+<!--
+ - 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>