aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2024-03-11 16:29:53 +0100
committerBenjamin Gaussorgues <benjamin.gaussorgues@nextcloud.com>2024-03-14 20:45:24 +0100
commit4cadb828502dce74f8ce41f85c21fceb15954cf6 (patch)
tree6b3375cf596ad87c8183e9d3884f607d1776a280 /apps
parent072393d0179ab4ccd50fbd9cd1eec4dfb7814615 (diff)
downloadnextcloud-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')
-rw-r--r--apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue96
-rw-r--r--apps/settings/src/components/AppStoreDiscover/PostType.vue81
-rw-r--r--apps/settings/src/constants/AppsConstants.js1
-rw-r--r--apps/settings/src/constants/AppstoreCategoryIcons.ts2
-rw-r--r--apps/settings/src/views/AppStore.vue42
-rw-r--r--apps/settings/src/views/AppStoreNavigation.vue10
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" />