diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-03-11 16:29:53 +0100 |
---|---|---|
committer | Benjamin Gaussorgues <benjamin.gaussorgues@nextcloud.com> | 2024-03-14 20:45:24 +0100 |
commit | 4cadb828502dce74f8ce41f85c21fceb15954cf6 (patch) | |
tree | 6b3375cf596ad87c8183e9d3884f607d1776a280 /apps | |
parent | 072393d0179ab4ccd50fbd9cd1eec4dfb7814615 (diff) | |
download | nextcloud-server-4cadb828502dce74f8ce41f85c21fceb15954cf6.tar.gz nextcloud-server-4cadb828502dce74f8ce41f85c21fceb15954cf6.zip |
feat(settings): Implement new app discover section for app management
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps')
6 files changed, 213 insertions, 19 deletions
diff --git a/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue new file mode 100644 index 00000000000..ae73b37dcd2 --- /dev/null +++ b/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> diff --git a/apps/settings/src/components/AppStoreDiscover/PostType.vue b/apps/settings/src/components/AppStoreDiscover/PostType.vue new file mode 100644 index 00000000000..0b451ec14fe --- /dev/null +++ b/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> diff --git a/apps/settings/src/constants/AppsConstants.js b/apps/settings/src/constants/AppsConstants.js index 8df7d44815a..8d0c5d38fb4 100644 --- a/apps/settings/src/constants/AppsConstants.js +++ b/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'), diff --git a/apps/settings/src/constants/AppstoreCategoryIcons.ts b/apps/settings/src/constants/AppstoreCategoryIcons.ts index 67b32431a81..cf8f558623a 100644 --- a/apps/settings/src/constants/AppstoreCategoryIcons.ts +++ b/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, diff --git a/apps/settings/src/views/AppStore.vue b/apps/settings/src/views/AppStore.vue index 3c3c53d4330..208b35ecdec 100644 --- a/apps/settings/src/views/AppStore.vue +++ b/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> diff --git a/apps/settings/src/views/AppStoreNavigation.vue b/apps/settings/src/views/AppStoreNavigation.vue index 1d5399a75ca..75ce207c80f 100644 --- a/apps/settings/src/views/AppStoreNavigation.vue +++ b/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" /> |