Browse Source

feat(settings): Implement new app discover section for app management

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Ferdinand Thiessen 1 month ago
parent
commit
4cadb82850

+ 96 - 0
apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue

@@ -0,0 +1,96 @@
+<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/AppDiscoverTypes.ts'
+
+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 logger from '../../logger'
+
+const PostType = defineAsyncComponent(() => import('./PostType.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 = (array) => {
+	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<IAppDiscoverElements[]>(generateUrl('/settings/api/apps/discover'))
+		elements.value = shuffleArray(data)
+	} 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
+	}
+	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>

+ 81 - 0
apps/settings/src/components/AppStoreDiscover/PostType.vue

@@ -0,0 +1,81 @@
+<template>
+	<article class="app-discover-post"
+		:class="{ 'app-discover-post--reverse': media && media.alignment === 'start' }">
+		<div v-if="headline || text" class="app-discover-post__text">
+			<h3>{{ translatedHeadline }}</h3>
+			<p>{{ translatedText }}</p>
+		</div>
+		<div v-if="media">
+			<img class="app-discover-post__media" :alt="mediaAlt" :src="mediaSource">
+		</div>
+	</article>
+</template>
+
+<script setup lang="ts">
+import { getLanguage } from '@nextcloud/l10n'
+import { computed } from 'vue'
+
+type ILocalizedValue<T> = Record<string, T | undefined> & { en: T }
+
+const props = defineProps<{
+	type: string
+
+	headline: ILocalizedValue<string>
+	text: ILocalizedValue<string>
+	link?: string
+	media: {
+		alignment: 'start'|'end'
+		content: ILocalizedValue<{ src: string, alt: string}>
+	}
+}>()
+
+const language = getLanguage()
+
+const getLocalizedValue = <T, >(dict: ILocalizedValue<T>) => dict[language] ?? dict[language.split('_')[0]] ?? dict.en
+
+const translatedText = computed(() => getLocalizedValue(props.text))
+const translatedHeadline = computed(() => getLocalizedValue(props.headline))
+
+const localizedMedia = computed(() => getLocalizedValue(props.media.content))
+
+const mediaSource = computed(() => localizedMedia.value?.src)
+const mediaAlt = ''
+</script>
+
+<style scoped lang="scss">
+.app-discover-post {
+	width: 100%;
+	background-color: var(--color-primary-element-light);
+	border-radius: var(--border-radius-rounded);
+
+	display: flex;
+	flex-direction: row;
+	&--reverse {
+		flex-direction: row-reverse;
+	}
+
+	h3 {
+		font-size: 24px;
+		font-weight: 600;
+		margin-block: 0 1em;
+	}
+
+	&__text {
+		padding: var(--border-radius-rounded);
+	}
+
+	&__media {
+		max-height: 300px;
+		max-width: 450px;
+		border-radius: var(--border-radius-rounded);
+		border-end-start-radius: 0;
+		border-start-start-radius: 0;
+	}
+
+	&--reverse &__media {
+		border-radius: var(--border-radius-rounded);
+		border-end-end-radius: 0;
+		border-start-end-radius: 0;
+	}
+}
+</style>

+ 1 - 0
apps/settings/src/constants/AppsConstants.js

@@ -24,6 +24,7 @@ import { translate as t } from '@nextcloud/l10n'
 
 /** Enum of verification constants, according to Apps */
 export const APPS_SECTION_ENUM = Object.freeze({
+	discover: t('settings', 'Discover'),
 	installed: t('settings', 'Your apps'),
 	enabled: t('settings', 'Active apps'),
 	disabled: t('settings', 'Disabled apps'),

+ 2 - 0
apps/settings/src/constants/AppstoreCategoryIcons.ts

@@ -39,6 +39,7 @@ import {
 	mdiOpenInApp,
 	mdiSecurity,
 	mdiStar,
+	mdiStarCircleOutline,
 	mdiStarShooting,
 	mdiTools,
 	mdiViewDashboard,
@@ -49,6 +50,7 @@ import {
  */
 export default Object.freeze({
 	// system special categories
+	discover: mdiStarCircleOutline,
 	installed: mdiAccount,
 	enabled: mdiCheck,
 	disabled: mdiClose,

+ 24 - 18
apps/settings/src/views/AppStore.vue

@@ -24,8 +24,11 @@
 <template>
 	<!-- Apps list -->
 	<NcAppContent class="app-settings-content"
-		:page-heading="pageHeading">
-		<NcEmptyContent v-if="isLoading"
+		:page-heading="appStoreLabel">
+		<h2 class="app-settings-content__label" v-text="viewLabel" />
+
+		<AppStoreDiscoverSection v-if="currentCategory === 'discover'" />
+		<NcEmptyContent v-else-if="isLoading"
 			class="empty-content__loading"
 			:name="t('settings', 'Loading app list')">
 			<template #icon>
@@ -38,36 +41,31 @@
 
 <script setup lang="ts">
 import { translate as t } from '@nextcloud/l10n'
-import { computed, getCurrentInstance, onBeforeMount, watch } from 'vue'
+import { computed, getCurrentInstance, onBeforeMount, watchEffect } from 'vue'
 import { useRoute } from 'vue-router/composables'
-import { APPS_SECTION_ENUM } from '../constants/AppsConstants.js'
+
 import { useAppsStore } from '../store/apps-store'
+import { APPS_SECTION_ENUM } from '../constants/AppsConstants'
 
 import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
 import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
 import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
 import AppList from '../components/AppList.vue'
+import AppStoreDiscoverSection from '../components/AppStoreDiscover/AppStoreDiscoverSection.vue'
 
 const route = useRoute()
 const store = useAppsStore()
 
 /**
- * ID of the current active category, default is `installed`
+ * ID of the current active category, default is `discover`
  */
-const currentCategory = computed(() => route.params?.category ?? 'installed')
+const currentCategory = computed(() => route.params?.category ?? 'discover')
 
-/**
- * The H1 to be used on the website
- */
-const pageHeading = computed(() => {
-	if (currentCategory.value in APPS_SECTION_ENUM) {
-		return APPS_SECTION_ENUM[currentCategory.value]
-	}
-	const category = store.getCategoryById(currentCategory.value)
-	return category?.displayName ?? t('settings', 'Apps')
-})
-watch([pageHeading], () => {
-	window.document.title = `${pageHeading.value} - Apps - Nextcloud`
+const appStoreLabel = t('settings', 'App Store')
+const viewLabel = computed(() => APPS_SECTION_ENUM[currentCategory.value] ?? store.getCategoryById(currentCategory.value)?.displayName ?? appStoreLabel)
+
+watchEffect(() => {
+	window.document.title = `${viewLabel.value} - ${appStoreLabel} - Nextcloud`
 })
 
 // TODO this part should be migrated to pinia
@@ -87,4 +85,12 @@ onBeforeMount(() => {
 .empty-content__loading {
 	height: 100%;
 }
+
+.app-settings-content__label {
+	margin-block-start: var(--app-navigation-padding);
+	margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
+	min-height: var(--default-clickable-area);
+	line-height: var(--default-clickable-area);
+	vertical-align: center;
+}
 </style>

+ 9 - 1
apps/settings/src/views/AppStoreNavigation.vue

@@ -2,9 +2,17 @@
 	<!-- Categories & filters -->
 	<NcAppNavigation :aria-label="t('settings', 'Apps')">
 		<template #list>
-			<NcAppNavigationItem id="app-category-your-apps"
+			<NcAppNavigationItem id="app-category-discover"
 				:to="{ name: 'apps' }"
 				:exact="true"
+				:name="APPS_SECTION_ENUM.discover">
+				<template #icon>
+					<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.discover" />
+				</template>
+			</NcAppNavigationItem>
+			<NcAppNavigationItem id="app-category-installed"
+				:to="{ name: 'apps-category', params: { category: 'installed'} }"
+				:exact="true"
 				:name="APPS_SECTION_ENUM.installed">
 				<template #icon>
 					<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.installed" />

+ 1 - 1
cypress/e2e/settings/apps.cy.ts

@@ -35,7 +35,7 @@ describe('Settings: App management', { testIsolation: true }, () => {
 		// I am logged in as the admin
 		cy.login(admin)
 		// I open the Apps management
-		cy.visit('/settings/apps')
+		cy.visit('/settings/apps/installed')
 	})
 
 	it('Can enable an installed app', () => {