]> source.dussan.org Git - nextcloud-server.git/commitdiff
refactor(app-store): Restructure app-store related components to easier identify...
authorFerdinand Thiessen <opensource@fthiessen.de>
Mon, 21 Oct 2024 10:27:51 +0000 (12:27 +0200)
committerFerdinand Thiessen <opensource@fthiessen.de>
Wed, 23 Oct 2024 10:58:52 +0000 (12:58 +0200)
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
31 files changed:
apps/settings/src/app-types.ts [deleted file]
apps/settings/src/components/AppList/AppLevelBadge.vue [deleted file]
apps/settings/src/components/AppList/AppScore.vue [deleted file]
apps/settings/src/components/AppStore/AppLevelBadge.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppScore.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppStoreDiscover/AppLink.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppStoreDiscover/AppType.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppStoreDiscover/CarouselType.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppStoreDiscover/PostType.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppStoreDiscover/ShowcaseType.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppStoreDiscover/common.ts [new file with mode: 0644]
apps/settings/src/components/AppStore/AppStoreSidebar/AppDescriptionTab.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppStoreSidebar/AppDetailsTab.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppStoreSidebar/AppReleasesTab.vue [new file with mode: 0644]
apps/settings/src/components/AppStoreDiscover/AppLink.vue [deleted file]
apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue [deleted file]
apps/settings/src/components/AppStoreDiscover/AppType.vue [deleted file]
apps/settings/src/components/AppStoreDiscover/CarouselType.vue [deleted file]
apps/settings/src/components/AppStoreDiscover/PostType.vue [deleted file]
apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue [deleted file]
apps/settings/src/components/AppStoreDiscover/common.ts [deleted file]
apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue [deleted file]
apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue [deleted file]
apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue [deleted file]
apps/settings/src/constants/AppDiscoverTypes.ts [deleted file]
apps/settings/src/constants/AppStoreCategoryIcons.ts [new file with mode: 0644]
apps/settings/src/constants/AppStoreConstants.ts [new file with mode: 0644]
apps/settings/src/constants/AppStoreDiscoverTypes.ts [new file with mode: 0644]
apps/settings/src/constants/AppStoreTypes.ts [new file with mode: 0644]
apps/settings/src/constants/AppsConstants.js [deleted file]
apps/settings/src/constants/AppstoreCategoryIcons.ts [deleted file]

diff --git a/apps/settings/src/app-types.ts b/apps/settings/src/app-types.ts
deleted file mode 100644 (file)
index 22c290b..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-export interface IAppstoreCategory {
-       /**
-        * The category ID
-        */
-       id: string
-       /**
-        * The display name (can be localized)
-        */
-       displayName: string
-       /**
-        * Inline SVG path
-        */
-       icon: string
-}
-
-export interface IAppstoreAppRelease {
-       version: string
-       translations: {
-               [key: string]: {
-                       changelog: string
-               }
-       }
-}
-
-export interface IAppstoreApp {
-       id: string
-       name: string
-       summary: string
-       description: string
-       licence: string
-       author: string[] | Record<string, string>
-       level: number
-       version: string
-       category: string|string[]
-
-       preview?: string
-       /** The preview is an icon */
-       previewAsIcon: boolean
-       screenshot?: string
-
-       active: boolean
-       internal: boolean
-       removeable: boolean
-       installed: boolean
-       canInstall: boolean
-       canUninstall: boolean
-       isCompatible: boolean
-       /** Available version to update to */
-       update?: string
-
-       score: number
-
-       appstoreData: Record<string, never>
-       releases?: IAppstoreAppRelease[]
-}
diff --git a/apps/settings/src/components/AppList/AppLevelBadge.vue b/apps/settings/src/components/AppList/AppLevelBadge.vue
deleted file mode 100644 (file)
index cceb5b0..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-<template>
-       <span v-if="isSupported || isFeatured"
-               class="app-level-badge"
-               :class="{ 'app-level-badge--supported': isSupported }"
-               :title="badgeTitle">
-               <NcIconSvgWrapper :path="badgeIcon" :size="20" inline />
-               {{ badgeText }}
-       </span>
-</template>
-
-<script setup lang="ts">
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-
-import { mdiCheck, mdiStarShooting } from '@mdi/js'
-import { translate as t } from '@nextcloud/l10n'
-import { computed } from 'vue'
-
-const props = defineProps<{
-       /**
-        * The app level
-        */
-       level?: number
-}>()
-
-const isSupported = computed(() => props.level === 300)
-const isFeatured = computed(() => props.level === 200)
-const badgeIcon = computed(() => isSupported.value ? mdiStarShooting : mdiCheck)
-const badgeText = computed(() => isSupported.value ? t('settings', 'Supported') : t('settings', 'Featured'))
-const badgeTitle = computed(() => isSupported.value
-       ? t('settings', 'This app is supported via your current Nextcloud subscription.')
-       : t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.'))
-</script>
-
-<style scoped lang="scss">
-.app-level-badge {
-       color: var(--color-text-maxcontrast);
-       background-color: transparent;
-       border: 1px solid var(--color-text-maxcontrast);
-       border-radius: var(--border-radius);
-
-       display: flex;
-       flex-direction: row;
-       gap: 6px;
-       padding: 3px 6px;
-       width: fit-content;
-
-       &--supported {
-               border-color: var(--color-success);
-               color: var(--color-success);
-       }
-}
-</style>
diff --git a/apps/settings/src/components/AppList/AppScore.vue b/apps/settings/src/components/AppList/AppScore.vue
deleted file mode 100644 (file)
index 7eebc62..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-
-<template>
-       <span role="img"
-               :aria-label="title"
-               :title="title"
-               class="app-score__wrapper">
-               <NcIconSvgWrapper v-for="index in fullStars"
-                       :key="`full-star-${index}`"
-                       :path="mdiStar"
-                       inline />
-               <NcIconSvgWrapper v-if="hasHalfStar" :path="mdiStarHalfFull" inline />
-               <NcIconSvgWrapper v-for="index in emptyStars"
-                       :key="`empty-star-${index}`"
-                       :path="mdiStarOutline"
-                       inline />
-       </span>
-</template>
-<script lang="ts">
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import { mdiStar, mdiStarHalfFull, mdiStarOutline } from '@mdi/js'
-import { translate as t } from '@nextcloud/l10n'
-import { defineComponent } from 'vue'
-
-export default defineComponent({
-       name: 'AppScore',
-       components: {
-               NcIconSvgWrapper,
-       },
-       props: {
-               score: {
-                       type: Number,
-                       required: true,
-               },
-       },
-       setup() {
-               return {
-                       mdiStar,
-                       mdiStarHalfFull,
-                       mdiStarOutline,
-               }
-       },
-       computed: {
-               title() {
-                       const appScore = (this.score * 5).toFixed(1)
-                       return t('settings', 'Community rating: {score}/5', { score: appScore })
-               },
-               fullStars() {
-                       return Math.floor(this.score * 5 + 0.25)
-               },
-               emptyStars() {
-                       return Math.min(Math.floor((1 - this.score) * 5 + 0.25), 5 - this.fullStars)
-               },
-               hasHalfStar() {
-                       return (this.fullStars + this.emptyStars) < 5
-               },
-       },
-})
-</script>
-<style scoped>
-.app-score__wrapper {
-       display: inline-flex;
-       color: var(--color-favorite, #a08b00);
-
-       > * {
-               vertical-align: text-bottom;
-       }
-}
-</style>
diff --git a/apps/settings/src/components/AppStore/AppLevelBadge.vue b/apps/settings/src/components/AppStore/AppLevelBadge.vue
new file mode 100644 (file)
index 0000000..cceb5b0
--- /dev/null
@@ -0,0 +1,56 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <span v-if="isSupported || isFeatured"
+               class="app-level-badge"
+               :class="{ 'app-level-badge--supported': isSupported }"
+               :title="badgeTitle">
+               <NcIconSvgWrapper :path="badgeIcon" :size="20" inline />
+               {{ badgeText }}
+       </span>
+</template>
+
+<script setup lang="ts">
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+
+import { mdiCheck, mdiStarShooting } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { computed } from 'vue'
+
+const props = defineProps<{
+       /**
+        * The app level
+        */
+       level?: number
+}>()
+
+const isSupported = computed(() => props.level === 300)
+const isFeatured = computed(() => props.level === 200)
+const badgeIcon = computed(() => isSupported.value ? mdiStarShooting : mdiCheck)
+const badgeText = computed(() => isSupported.value ? t('settings', 'Supported') : t('settings', 'Featured'))
+const badgeTitle = computed(() => isSupported.value
+       ? t('settings', 'This app is supported via your current Nextcloud subscription.')
+       : t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.'))
+</script>
+
+<style scoped lang="scss">
+.app-level-badge {
+       color: var(--color-text-maxcontrast);
+       background-color: transparent;
+       border: 1px solid var(--color-text-maxcontrast);
+       border-radius: var(--border-radius);
+
+       display: flex;
+       flex-direction: row;
+       gap: 6px;
+       padding: 3px 6px;
+       width: fit-content;
+
+       &--supported {
+               border-color: var(--color-success);
+               color: var(--color-success);
+       }
+}
+</style>
diff --git a/apps/settings/src/components/AppStore/AppScore.vue b/apps/settings/src/components/AppStore/AppScore.vue
new file mode 100644 (file)
index 0000000..844beef
--- /dev/null
@@ -0,0 +1,74 @@
+<!--
+  - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+       <span role="img"
+               :aria-label="title"
+               :title="title"
+               class="app-score__wrapper">
+               <NcIconSvgWrapper v-for="index in fullStars"
+                       :key="`full-star-${index}`"
+                       :path="mdiStar"
+                       inline />
+               <NcIconSvgWrapper v-if="hasHalfStar" :path="mdiStarHalfFull" inline />
+               <NcIconSvgWrapper v-for="index in emptyStars"
+                       :key="`empty-star-${index}`"
+                       :path="mdiStarOutline"
+                       inline />
+       </span>
+</template>
+
+<script lang="ts">
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import { mdiStar, mdiStarHalfFull, mdiStarOutline } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+export default defineComponent({
+       name: 'AppScore',
+       components: {
+               NcIconSvgWrapper,
+       },
+       props: {
+               score: {
+                       type: Number,
+                       required: true,
+               },
+       },
+       setup() {
+               return {
+                       mdiStar,
+                       mdiStarHalfFull,
+                       mdiStarOutline,
+               }
+       },
+       computed: {
+               title() {
+                       const appScore = (this.score * 5).toFixed(1)
+                       return t('settings', 'Community rating: {score}/5', { score: appScore })
+               },
+               fullStars() {
+                       return Math.floor(this.score * 5 + 0.25)
+               },
+               emptyStars() {
+                       return Math.min(Math.floor((1 - this.score) * 5 + 0.25), 5 - this.fullStars)
+               },
+               hasHalfStar() {
+                       return (this.fullStars + this.emptyStars) < 5
+               },
+       },
+})
+</script>
+
+<style scoped lang="scss">
+.app-score__wrapper {
+       display: inline-flex;
+       color: var(--color-favorite, #a08b00);
+
+       > * {
+               vertical-align: text-bottom;
+       }
+}
+</style>
diff --git a/apps/settings/src/components/AppStore/AppStoreDiscover/AppLink.vue b/apps/settings/src/components/AppStore/AppStoreDiscover/AppLink.vue
new file mode 100644 (file)
index 0000000..8f180f1
--- /dev/null
@@ -0,0 +1,98 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <a v-if="linkProps" v-bind="linkProps">
+               <slot />
+       </a>
+       <RouterLink v-else-if="routerProps" v-bind="routerProps">
+               <slot />
+       </RouterLink>
+</template>
+
+<script lang="ts">
+import type { RouterLinkProps } from 'vue-router/types/router.js'
+
+import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import { defineComponent } from 'vue'
+import { RouterLink } from 'vue-router'
+import type { INavigationEntry } from '../../../../../../core/src/types/navigation'
+
+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
+ */
+export default defineComponent({
+       name: 'AppLink',
+
+       components: { RouterLink },
+
+       props: {
+               href: {
+                       type: String,
+                       required: true,
+               },
+       },
+
+       data() {
+               return {
+                       routerProps: undefined as RouterLinkProps|undefined,
+                       linkProps: undefined as Record<string, string>|undefined,
+               }
+       },
+
+       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
+                               }
+
+                               // Fallback to show the app store entry
+                               this.routerProps = {
+                                       to: {
+                                               name: 'apps-details',
+                                               params: {
+                                                       category: this.$route.params?.category ?? 'discover',
+                                                       id: appId,
+                                               },
+                                       },
+                               }
+                       },
+               },
+       },
+})
+</script>
diff --git a/apps/settings/src/components/AppStore/AppStoreDiscover/AppType.vue b/apps/settings/src/components/AppStore/AppStoreDiscover/AppType.vue
new file mode 100644 (file)
index 0000000..a1cba13
--- /dev/null
@@ -0,0 +1,99 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <AppItem v-if="app"
+               :app="app"
+               category="discover"
+               class="app-discover-app"
+               inline />
+       <a v-else
+               class="app-discover-app app-discover-app__skeleton"
+               :href="appStoreLink"
+               target="_blank"
+               :title="modelValue.appId"
+               rel="noopener noreferrer">
+               <!-- This is a fallback skeleton -->
+               <span class="skeleton-element" />
+               <span class="skeleton-element" />
+               <span class="skeleton-element" />
+               <span class="skeleton-element" />
+               <span class="skeleton-element" />
+       </a>
+</template>
+
+<script setup lang="ts">
+import type { IAppDiscoverApp } from '../../../constants/AppDiscoverTypes'
+
+import { computed } from 'vue'
+import { useAppsStore } from '../../../store/apps-store.ts'
+
+import AppItem from '../AppItem/AppItem.vue'
+
+const props = defineProps<{
+       modelValue: IAppDiscoverApp
+}>()
+
+const store = useAppsStore()
+const app = computed(() => store.getAppById(props.modelValue.appId))
+
+const appStoreLink = computed(() => props.modelValue.appId ? `https://apps.nextcloud.com/apps/${props.modelValue.appId}` : '#')
+</script>
+
+<style scoped lang="scss">
+.app-discover-app {
+       width: 100% !important; // full with of the showcase item
+
+       &:hover {
+               background: var(--color-background-hover);
+               border-radius: var(--border-radius-rounded);
+       }
+
+       &__skeleton {
+               display: flex;
+               flex-direction: column;
+               gap: 8px;
+
+               padding: 30px; // Same as AppItem
+
+               > :first-child {
+                       height: 50%;
+                       min-height: 130px;
+               }
+
+               > :nth-child(2) {
+                       width: 50px;
+               }
+
+               > :nth-child(5) {
+                       height: 20px;
+                       width: 100px;
+               }
+
+               > :not(:first-child) {
+                       border-radius: 4px;
+               }
+       }
+}
+
+.skeleton-element {
+       min-height: var(--default-font-size, 15px);
+
+       background: linear-gradient(90deg, var(--color-background-dark), var(--color-background-darker), var(--color-background-dark));
+       background-size: 400% 400%;
+       animation: gradient 6s ease infinite;
+}
+
+@keyframes gradient {
+       0% {
+               background-position: 0% 50%;
+       }
+       50% {
+               background-position: 100% 50%;
+       }
+       100% {
+               background-position: 0% 50%;
+       }
+}
+</style>
diff --git a/apps/settings/src/components/AppStore/AppStoreDiscover/CarouselType.vue b/apps/settings/src/components/AppStore/AppStoreDiscover/CarouselType.vue
new file mode 100644 (file)
index 0000000..125aedd
--- /dev/null
@@ -0,0 +1,206 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <section :aria-roledescription="t('settings', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
+               <h3 v-if="headline" :id="headingId">
+                       {{ translatedHeadline }}
+               </h3>
+               <div class="app-discover-carousel__wrapper">
+                       <div class="app-discover-carousel__button-wrapper">
+                               <NcButton class="app-discover-carousel__button app-discover-carousel__button--previous"
+                                       type="tertiary-no-background"
+                                       :aria-label="t('settings', 'Previous slide')"
+                                       :disabled="!hasPrevious"
+                                       @click="currentIndex -= 1">
+                                       <template #icon>
+                                               <NcIconSvgWrapper :path="mdiChevronLeft" />
+                                       </template>
+                               </NcButton>
+                       </div>
+
+                       <Transition :name="transitionName" mode="out-in">
+                               <PostType v-bind="shownElement"
+                                       :key="shownElement.id ?? currentIndex"
+                                       :aria-labelledby="`${internalId}-tab-${currentIndex}`"
+                                       :dom-id="`${internalId}-tabpanel-${currentIndex}`"
+                                       inline
+                                       role="tabpanel" />
+                       </Transition>
+
+                       <div class="app-discover-carousel__button-wrapper">
+                               <NcButton class="app-discover-carousel__button app-discover-carousel__button--next"
+                                       type="tertiary-no-background"
+                                       :aria-label="t('settings', 'Next slide')"
+                                       :disabled="!hasNext"
+                                       @click="currentIndex += 1">
+                                       <template #icon>
+                                               <NcIconSvgWrapper :path="mdiChevronRight" />
+                                       </template>
+                               </NcButton>
+                       </div>
+               </div>
+               <div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('settings', 'Choose slide to display')">
+                       <NcButton v-for="index of content.length"
+                               :id="`${internalId}-tab-${index}`"
+                               :key="index"
+                               :aria-label="t('settings', '{index} of {total}', { index, total: content.length })"
+                               :aria-controls="`${internalId}-tabpanel-${index}`"
+                               :aria-selected="`${currentIndex === (index - 1)}`"
+                               role="tab"
+                               type="tertiary-no-background"
+                               @click="currentIndex = index - 1">
+                               <template #icon>
+                                       <NcIconSvgWrapper :path="currentIndex === (index - 1) ? mdiCircleSlice8 : mdiCircleOutline" />
+                               </template>
+                       </NcButton>
+               </div>
+       </section>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { IAppDiscoverCarousel } from '../../../constants/AppDiscoverTypes.ts'
+
+import { mdiChevronLeft, mdiChevronRight, mdiCircleOutline, mdiCircleSlice8 } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { computed, defineComponent, nextTick, ref, watch } from 'vue'
+import { commonAppDiscoverProps } from './common.ts'
+import { useLocalizedValue } from '../../../composables/useGetLocalizedValue.ts'
+
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import PostType from './PostType.vue'
+
+export default defineComponent({
+       name: 'CarouselType',
+
+       components: {
+               NcButton,
+               NcIconSvgWrapper,
+               PostType,
+       },
+
+       props: {
+               ...commonAppDiscoverProps,
+
+               /**
+                * The content of the carousel
+                */
+               content: {
+                       type: Array as PropType<IAppDiscoverCarousel['content']>,
+                       required: true,
+               },
+       },
+
+       setup(props) {
+               const translatedHeadline = useLocalizedValue(computed(() => props.headline))
+
+               const currentIndex = ref(Math.min(1, props.content.length - 1))
+               const shownElement = ref(props.content[currentIndex.value])
+               const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
+               const hasPrevious = computed(() => currentIndex.value > 0)
+
+               const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
+               const headingId = computed(() => `${internalId.value}-h`)
+
+               const transitionName = ref('slide-in')
+               watch(() => currentIndex.value, (o, n) => {
+                       if (o < n) {
+                               transitionName.value = 'slide-in'
+                       } else {
+                               transitionName.value = 'slide-out'
+                       }
+
+                       // Wait next tick
+                       nextTick(() => {
+                               shownElement.value = props.content[currentIndex.value]
+                       })
+               })
+
+               return {
+                       t,
+                       internalId,
+                       headingId,
+
+                       hasNext,
+                       hasPrevious,
+                       currentIndex,
+                       shownElement,
+
+                       transitionName,
+
+                       translatedHeadline,
+
+                       mdiChevronLeft,
+                       mdiChevronRight,
+                       mdiCircleOutline,
+                       mdiCircleSlice8,
+               }
+       },
+})
+</script>
+
+<style scoped lang="scss">
+h3 {
+       font-size: 24px;
+       font-weight: 600;
+       margin-block: 0 1em;
+}
+
+.app-discover-carousel {
+       &__wrapper {
+               display: flex;
+       }
+
+       &__button {
+               color: var(--color-text-maxcontrast);
+               position: absolute;
+               top: calc(50% - 22px); // 50% minus half of button height
+
+               &-wrapper {
+                       position: relative;
+               }
+
+               // See padding of discover section
+               &--next {
+                       inset-inline-end: -54px;
+               }
+               &--previous {
+                       inset-inline-start: -54px;
+               }
+       }
+
+       &__tabs {
+               display: flex;
+               flex-direction: row;
+               justify-content: center;
+
+               > * {
+                       color: var(--color-text-maxcontrast);
+               }
+       }
+}
+</style>
+
+<style>
+.slide-in-enter-active,
+.slide-in-leave-active,
+.slide-out-enter-active,
+.slide-out-leave-active {
+  transition: all .4s ease-out;
+}
+
+.slide-in-leave-to,
+.slide-out-enter {
+  opacity: 0;
+  transform: translateX(50%);
+}
+
+.slide-in-enter,
+.slide-out-leave-to {
+  opacity: 0;
+  transform: translateX(-50%);
+}
+</style>
diff --git a/apps/settings/src/components/AppStore/AppStoreDiscover/PostType.vue b/apps/settings/src/components/AppStore/AppStoreDiscover/PostType.vue
new file mode 100644 (file)
index 0000000..7703ef3
--- /dev/null
@@ -0,0 +1,299 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <article :id="domId"
+               ref="container"
+               class="app-discover-post"
+               :class="{
+                       'app-discover-post--reverse': media && media.alignment === 'start',
+                       'app-discover-post--small': isSmallWidth
+               }">
+               <component :is="link ? 'AppLink' : 'div'"
+                       v-if="headline || text"
+                       :href="link"
+                       class="app-discover-post__text">
+                       <component :is="inline ? 'h4' : 'h3'">
+                               {{ translatedHeadline }}
+                       </component>
+                       <p>{{ translatedText }}</p>
+               </component>
+               <component :is="mediaLink ? 'AppLink' : 'div'"
+                       v-if="mediaSources"
+                       :href="mediaLink"
+                       class="app-discover-post__media"
+                       :class="{
+                               'app-discover-post__media--fullwidth': isFullWidth,
+                               'app-discover-post__media--start': media?.alignment === 'start',
+                               'app-discover-post__media--end': media?.alignment === 'end',
+                       }">
+                       <component :is="isImage ? 'picture' : 'video'"
+                               ref="mediaElement"
+                               class="app-discover-post__media-element"
+                               :muted="!isImage"
+                               :playsinline="!isImage"
+                               :preload="!isImage && 'auto'"
+                               @ended="hasPlaybackEnded = true">
+                               <source v-for="source of mediaSources"
+                                       :key="source.src"
+                                       :src="isImage ? undefined : generatePrivacyUrl(source.src)"
+                                       :srcset="isImage ? generatePrivacyUrl(source.src) : undefined"
+                                       :type="source.mime">
+                               <img v-if="isImage"
+                                       :src="generatePrivacyUrl(mediaSources[0].src)"
+                                       :alt="mediaAlt">
+                       </component>
+                       <div class="app-discover-post__play-icon-wrapper">
+                               <NcIconSvgWrapper v-if="!isImage && showPlayVideo"
+                                       class="app-discover-post__play-icon"
+                                       :path="mdiPlayCircleOutline"
+                                       :size="92" />
+                       </div>
+               </component>
+       </article>
+</template>
+
+<script lang="ts">
+import type { IAppDiscoverPost } from '../../../constants/AppDiscoverTypes.ts'
+import type { PropType } from 'vue'
+
+import { mdiPlayCircleOutline } from '@mdi/js'
+import { generateUrl } from '@nextcloud/router'
+import { useElementSize, useElementVisibility } from '@vueuse/core'
+import { computed, defineComponent, ref, watchEffect } from 'vue'
+import { commonAppDiscoverProps } from './common'
+import { useLocalizedValue } from '../../../composables/useGetLocalizedValue'
+
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import AppLink from './AppLink.vue'
+
+export default defineComponent({
+       components: {
+               AppLink,
+               NcIconSvgWrapper,
+       },
+
+       props: {
+               ...commonAppDiscoverProps,
+
+               text: {
+                       type: Object as PropType<IAppDiscoverPost['text']>,
+                       required: false,
+                       default: () => null,
+               },
+
+               media: {
+                       type: Object as PropType<IAppDiscoverPost['media']>,
+                       required: false,
+                       default: () => null,
+               },
+
+               inline: {
+                       type: Boolean,
+                       required: false,
+                       default: false,
+               },
+
+               domId: {
+                       type: String,
+                       required: false,
+                       default: null,
+               },
+       },
+
+       setup(props) {
+               const translatedHeadline = useLocalizedValue(computed(() => props.headline))
+               const translatedText = useLocalizedValue(computed(() => props.text))
+               const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
+
+               const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
+               const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
+
+               const isImage = computed(() => mediaSources?.value?.[0].mime.startsWith('image/') === true)
+               /**
+                * Is the media is shown full width
+                */
+               const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
+
+               /**
+                * Link on the media
+                * Fallback to post link to prevent link inside link (which is invalid HTML)
+                */
+               const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
+
+               const hasPlaybackEnded = ref(false)
+               const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
+
+               /**
+                * The content is sized / styles are applied based on the container width
+                * To make it responsive even for inline usage and when opening / closing the sidebar / navigation
+                */
+               const container = ref<HTMLElement>()
+               const { width: containerWidth } = useElementSize(container)
+               const isSmallWidth = computed(() => containerWidth.value < 600)
+
+               /**
+                * Generate URL for cached media to prevent user can be tracked
+                * @param url The URL to resolve
+                */
+               const generatePrivacyUrl = (url: string) => url.startsWith('/') ? url : generateUrl('/settings/api/apps/media?fileName={fileName}', { fileName: url })
+
+               const mediaElement = ref<HTMLVideoElement|HTMLPictureElement>()
+               const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
+               watchEffect(() => {
+                       // Only if media is video
+                       if (!isImage.value && mediaElement.value) {
+                               const video = mediaElement.value as HTMLVideoElement
+
+                               if (mediaIsVisible.value) {
+                                       // Ensure video is muted - otherwise .play() will be blocked by browsers
+                                       video.muted = true
+                                       // If visible start playback
+                                       video.play()
+                               } else {
+                                       // If not visible pause the playback
+                                       video.pause()
+                                       // If the animation has ended reset
+                                       if (video.ended) {
+                                               video.currentTime = 0
+                                               hasPlaybackEnded.value = false
+                                       }
+                               }
+                       }
+               })
+
+               return {
+                       mdiPlayCircleOutline,
+
+                       container,
+
+                       translatedText,
+                       translatedHeadline,
+                       mediaElement,
+                       mediaSources,
+                       mediaAlt,
+                       mediaLink,
+
+                       hasPlaybackEnded,
+                       showPlayVideo,
+
+                       isFullWidth,
+                       isSmallWidth,
+                       isImage,
+
+                       generatePrivacyUrl,
+               }
+       },
+})
+</script>
+
+<style scoped lang="scss">
+.app-discover-post {
+       max-height: 300px;
+       width: 100%;
+       background-color: var(--color-primary-element-light);
+       border-radius: var(--border-radius-rounded);
+
+       display: flex;
+       flex-direction: row;
+       justify-content: start;
+
+       &--reverse {
+               flex-direction: row-reverse;
+       }
+
+       h3, h4 {
+               font-size: 24px;
+               font-weight: 600;
+               margin-block: 0 1em;
+       }
+
+       &__text {
+               display: block;
+               width: 100%;
+               padding: var(--border-radius-rounded);
+               overflow-y: scroll;
+       }
+
+       // If there is media next to the text we do not want a padding on the bottom as this looks weird when scrolling
+       &:has(&__media) &__text {
+               padding-block-end: 0;
+       }
+
+       &__media {
+               display: block;
+               overflow: hidden;
+
+               max-width: 450px;
+               border-radius: var(--border-radius-rounded);
+
+               &--fullwidth {
+                       max-width: unset;
+                       max-height: unset;
+               }
+
+               &--end {
+                       border-end-start-radius: 0;
+                       border-start-start-radius: 0;
+               }
+
+               &--start {
+                       border-end-end-radius: 0;
+                       border-start-end-radius: 0;
+               }
+
+               img, &-element {
+                       height: 100%;
+                       width: 100%;
+                       object-fit: cover;
+                       object-position: center;
+               }
+       }
+
+       &__play-icon {
+               position: absolute;
+               top: -46px; // half of the icon height
+               inset-inline-end: -46px; // half of the icon width
+
+               &-wrapper {
+                       position: relative;
+                       top: -50%;
+                       inset-inline-start: -50%;
+               }
+       }
+}
+
+.app-discover-post--small {
+       &.app-discover-post {
+               flex-direction: column;
+               max-height: 500px;
+
+               &--reverse {
+                       flex-direction: column-reverse;
+               }
+       }
+
+       .app-discover-post {
+               &__text {
+                       flex: 1 1 50%;
+               }
+
+               &__media {
+                       min-width: 100%;
+
+                       &--end {
+                               border-radius: var(--border-radius-rounded);
+                               border-start-end-radius: 0;
+                               border-start-start-radius: 0;
+                       }
+
+                       &--start {
+                               border-radius: var(--border-radius-rounded);
+                               border-end-end-radius: 0;
+                               border-end-start-radius: 0;
+                       }
+               }
+       }
+}
+</style>
diff --git a/apps/settings/src/components/AppStore/AppStoreDiscover/ShowcaseType.vue b/apps/settings/src/components/AppStore/AppStoreDiscover/ShowcaseType.vue
new file mode 100644 (file)
index 0000000..3d9ab7f
--- /dev/null
@@ -0,0 +1,122 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <section ref="container"
+               class="app-discover-showcase"
+               :class="{
+                       'app-discover-showcase--small': isSmallWidth,
+                       'app-discover-showcase--extra-small': isExtraSmallWidth,
+               }">
+               <h3 v-if="translatedHeadline">
+                       {{ translatedHeadline }}
+               </h3>
+               <ul class="app-discover-showcase__list">
+                       <li v-for="(item, index) of content"
+                               :key="item.id ?? index"
+                               class="app-discover-showcase__item">
+                               <PostType v-if="item.type === 'post'"
+                                       v-bind="item"
+                                       inline />
+                               <AppType v-else-if="item.type === 'app'" :model-value="item" />
+                       </li>
+               </ul>
+       </section>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { IAppDiscoverShowcase } from '../../../constants/AppDiscoverTypes.ts'
+
+import { translate as t } from '@nextcloud/l10n'
+import { useElementSize } from '@vueuse/core'
+import { computed, defineComponent, ref } from 'vue'
+import { commonAppDiscoverProps } from './common.ts'
+import { useLocalizedValue } from '../../../composables/useGetLocalizedValue.ts'
+
+import AppType from './AppType.vue'
+import PostType from './PostType.vue'
+
+export default defineComponent({
+       name: 'ShowcaseType',
+
+       components: {
+               AppType,
+               PostType,
+       },
+
+       props: {
+               ...commonAppDiscoverProps,
+
+               /**
+                * The content of the carousel
+                */
+               content: {
+                       type: Array as PropType<IAppDiscoverShowcase['content']>,
+                       required: true,
+               },
+       },
+
+       setup(props) {
+               const translatedHeadline = useLocalizedValue(computed(() => props.headline))
+
+               /**
+                * Make the element responsive based on the container width to also handle open navigation or sidebar
+                */
+               const container = ref<HTMLElement>()
+               const { width: containerWidth } = useElementSize(container)
+               const isSmallWidth = computed(() => containerWidth.value < 768)
+               const isExtraSmallWidth = computed(() => containerWidth.value < 512)
+
+               return {
+                       t,
+
+                       container,
+                       isSmallWidth,
+                       isExtraSmallWidth,
+                       translatedHeadline,
+               }
+       },
+})
+</script>
+
+<style scoped lang="scss">
+$item-gap: calc(var(--default-clickable-area, 44px) / 2);
+
+h3 {
+       font-size: 24px;
+       font-weight: 600;
+       margin-block: 0 1em;
+}
+
+.app-discover-showcase {
+       &__list {
+               list-style: none;
+
+               display: flex;
+               flex-wrap: wrap;
+               gap: $item-gap;
+       }
+
+       &__item {
+               display: flex;
+               align-items: stretch;
+
+               position: relative;
+               width: calc(33% - $item-gap);
+       }
+}
+
+.app-discover-showcase--small {
+       .app-discover-showcase__item {
+               width: calc(50% - $item-gap);
+       }
+}
+
+.app-discover-showcase--extra-small {
+       .app-discover-showcase__item {
+               width: 100%;
+       }
+}
+</style>
diff --git a/apps/settings/src/components/AppStore/AppStoreDiscover/common.ts b/apps/settings/src/components/AppStore/AppStoreDiscover/common.ts
new file mode 100644 (file)
index 0000000..ef86a56
--- /dev/null
@@ -0,0 +1,48 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * 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'
+
+/**
+ * Common Props for all app discover types
+ */
+export const commonAppDiscoverProps = {
+       type: {
+               type: String as PropType<IAppDiscoverElement['type']>,
+               required: true,
+               validator: (v: unknown) => typeof v === 'string' && APP_DISCOVER_KNOWN_TYPES.includes(v as never),
+       },
+
+       id: {
+               type: String as PropType<IAppDiscoverElement['id']>,
+               required: true,
+       },
+
+       date: {
+               type: Number as PropType<IAppDiscoverElement['date']>,
+               required: false,
+               default: undefined,
+       },
+
+       expiryDate: {
+               type: Number as PropType<IAppDiscoverElement['expiryDate']>,
+               required: false,
+               default: undefined,
+       },
+
+       headline: {
+               type: Object as PropType<IAppDiscoverElement['headline']>,
+               required: false,
+               default: () => null,
+       },
+
+       link: {
+               type: String as PropType<IAppDiscoverElement['link']>,
+               required: false,
+               default: () => null,
+       },
+} as const
diff --git a/apps/settings/src/components/AppStore/AppStoreSidebar/AppDescriptionTab.vue b/apps/settings/src/components/AppStore/AppStoreSidebar/AppDescriptionTab.vue
new file mode 100644 (file)
index 0000000..4b94be2
--- /dev/null
@@ -0,0 +1,38 @@
+<!--
+  - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+       <NcAppSidebarTab id="desc"
+               :name="t('settings', 'Description')"
+               :order="0">
+               <template #icon>
+                       <NcIconSvgWrapper :path="mdiTextShort" />
+               </template>
+               <div class="app-description">
+                       <Markdown :text="app.description" :min-heading="4" />
+               </div>
+       </NcAppSidebarTab>
+</template>
+
+<script setup lang="ts">
+import type { IAppstoreApp } from '../../../app-types'
+
+import { mdiTextShort } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+
+import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import Markdown from '../../Markdown.vue'
+
+defineProps<{
+       app: IAppstoreApp,
+}>()
+</script>
+
+<style scoped lang="scss">
+.app-description {
+       padding: 12px;
+}
+</style>
diff --git a/apps/settings/src/components/AppStore/AppStoreSidebar/AppDetailsTab.vue b/apps/settings/src/components/AppStore/AppStoreSidebar/AppDetailsTab.vue
new file mode 100644 (file)
index 0000000..b94a2ae
--- /dev/null
@@ -0,0 +1,417 @@
+<!--
+  - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+       <NcAppSidebarTab id="details"
+               :name="t('settings', 'Details')"
+               :order="1">
+               <template #icon>
+                       <NcIconSvgWrapper :path="mdiTextBox" />
+               </template>
+               <div class="app-details">
+                       <div class="app-details__actions">
+                               <div v-if="app.active && canLimitToGroups(app)" class="app-details__actions-groups">
+                                       <input :id="`groups_enable_${app.id}`"
+                                               v-model="groupCheckedAppsData"
+                                               type="checkbox"
+                                               :value="app.id"
+                                               class="groups-enable__checkbox checkbox"
+                                               @change="setGroupLimit">
+                                       <label :for="`groups_enable_${app.id}`">{{ t('settings', 'Limit to groups') }}</label>
+                                       <input type="hidden"
+                                               class="group_select"
+                                               :title="t('settings', 'All')"
+                                               value="">
+                                       <br>
+                                       <label for="limitToGroups">
+                                               <span>{{ t('settings', 'Limit app usage to groups') }}</span>
+                                       </label>
+                                       <NcSelect v-if="isLimitedToGroups(app)"
+                                               input-id="limitToGroups"
+                                               :options="groups"
+                                               :value="appGroups"
+                                               :limit="5"
+                                               label="name"
+                                               :multiple="true"
+                                               :close-on-select="false"
+                                               @option:selected="addGroupLimitation"
+                                               @option:deselected="removeGroupLimitation"
+                                               @search="asyncFindGroup">
+                                               <span slot="noResult">{{ t('settings', 'No results') }}</span>
+                                       </NcSelect>
+                               </div>
+                               <div class="app-details__actions-manage">
+                                       <input v-if="app.update"
+                                               class="update primary"
+                                               type="button"
+                                               :value="t('settings', 'Update to {version}', { version: app.update })"
+                                               :disabled="installing || isLoading"
+                                               @click="update(app.id)">
+                                       <input v-if="app.canUnInstall"
+                                               class="uninstall"
+                                               type="button"
+                                               :value="t('settings', 'Remove')"
+                                               :disabled="installing || isLoading"
+                                               @click="remove(app.id)">
+                                       <input v-if="app.active"
+                                               class="enable"
+                                               type="button"
+                                               :value="t('settings','Disable')"
+                                               :disabled="installing || isLoading"
+                                               @click="disable(app.id)">
+                                       <input v-if="!app.active && (app.canInstall || app.isCompatible)"
+                                               :title="enableButtonTooltip"
+                                               :aria-label="enableButtonTooltip"
+                                               class="enable primary"
+                                               type="button"
+                                               :value="enableButtonText"
+                                               :disabled="!app.canInstall || installing || isLoading"
+                                               @click="enable(app.id)">
+                                       <input v-else-if="!app.active && !app.canInstall"
+                                               :title="forceEnableButtonTooltip"
+                                               :aria-label="forceEnableButtonTooltip"
+                                               class="enable force"
+                                               type="button"
+                                               :value="forceEnableButtonText"
+                                               :disabled="installing || isLoading"
+                                               @click="forceEnable(app.id)">
+                               </div>
+                       </div>
+
+                       <ul class="app-details__dependencies">
+                               <li v-if="app.missingMinOwnCloudVersion">
+                                       {{ t('settings', 'This app has no minimum Nextcloud version assigned. This will be an error in the future.') }}
+                               </li>
+                               <li v-if="app.missingMaxOwnCloudVersion">
+                                       {{ t('settings', 'This app has no maximum Nextcloud version assigned. This will be an error in the future.') }}
+                               </li>
+                               <li v-if="!app.canInstall">
+                                       {{ t('settings', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
+                                       <ul class="missing-dependencies">
+                                               <li v-for="(dep, index) in app.missingDependencies" :key="index">
+                                                       {{ dep }}
+                                               </li>
+                                       </ul>
+                               </li>
+                       </ul>
+
+                       <div v-if="lastModified && !app.shipped" class="app-details__section">
+                               <h4>
+                                       {{ t('settings', 'Latest updated') }}
+                               </h4>
+                               <NcDateTime :timestamp="lastModified" />
+                       </div>
+
+                       <div class="app-details__section">
+                               <h4>
+                                       {{ t('settings', 'Author') }}
+                               </h4>
+                               <p class="app-details__authors">
+                                       {{ appAuthors }}
+                               </p>
+                       </div>
+
+                       <div class="app-details__section">
+                               <h4>
+                                       {{ t('settings', 'Categories') }}
+                               </h4>
+                               <p>
+                                       {{ appCategories }}
+                               </p>
+                       </div>
+
+                       <div v-if="externalResources.length > 0" class="app-details__section">
+                               <h4>{{ t('settings', 'Resources') }}</h4>
+                               <ul class="app-details__documentation" :aria-label="t('settings', 'Documentation')">
+                                       <li v-for="resource of externalResources" :key="resource.id">
+                                               <a class="appslink"
+                                                       :href="resource.href"
+                                                       target="_blank"
+                                                       rel="noreferrer noopener">
+                                                       {{ resource.label }} â†—
+                                               </a>
+                                       </li>
+                               </ul>
+                       </div>
+
+                       <div class="app-details__section">
+                               <h4>{{ t('settings', 'Interact') }}</h4>
+                               <div class="app-details__interact">
+                                       <NcButton :disabled="!app.bugs"
+                                               :href="app.bugs ?? '#'"
+                                               :aria-label="t('settings', 'Report a bug')"
+                                               :title="t('settings', 'Report a bug')">
+                                               <template #icon>
+                                                       <NcIconSvgWrapper :path="mdiBug" />
+                                               </template>
+                                       </NcButton>
+                                       <NcButton :disabled="!app.bugs"
+                                               :href="app.bugs ?? '#'"
+                                               :aria-label="t('settings', 'Request feature')"
+                                               :title="t('settings', 'Request feature')">
+                                               <template #icon>
+                                                       <NcIconSvgWrapper :path="mdiFeatureSearch" />
+                                               </template>
+                                       </NcButton>
+                                       <NcButton v-if="app.appstoreData?.discussion"
+                                               :href="app.appstoreData.discussion"
+                                               :aria-label="t('settings', 'Ask questions or discuss')"
+                                               :title="t('settings', 'Ask questions or discuss')">
+                                               <template #icon>
+                                                       <NcIconSvgWrapper :path="mdiTooltipQuestion" />
+                                               </template>
+                                       </NcButton>
+                                       <NcButton v-if="!app.internal"
+                                               :href="rateAppUrl"
+                                               :aria-label="t('settings', 'Rate the app')"
+                                               :title="t('settings', 'Rate')">
+                                               <template #icon>
+                                                       <NcIconSvgWrapper :path="mdiStar" />
+                                               </template>
+                                       </NcButton>
+                               </div>
+                       </div>
+               </div>
+       </NcAppSidebarTab>
+</template>
+
+<script>
+import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+
+import AppManagement from '../../../mixins/AppManagement.js'
+import { mdiBug, mdiFeatureSearch, mdiStar, mdiTextBox, mdiTooltipQuestion } from '@mdi/js'
+import { useAppsStore } from '../../../store/apps-store'
+
+export default {
+       name: 'AppDetailsTab',
+
+       components: {
+               NcAppSidebarTab,
+               NcButton,
+               NcDateTime,
+               NcIconSvgWrapper,
+               NcSelect,
+       },
+       mixins: [AppManagement],
+
+       props: {
+               app: {
+                       type: Object,
+                       required: true,
+               },
+       },
+
+       setup() {
+               const store = useAppsStore()
+
+               return {
+                       store,
+
+                       mdiBug,
+                       mdiFeatureSearch,
+                       mdiStar,
+                       mdiTextBox,
+                       mdiTooltipQuestion,
+               }
+       },
+
+       data() {
+               return {
+                       groupCheckedAppsData: false,
+               }
+       },
+
+       computed: {
+               lastModified() {
+                       return (this.app.appstoreData?.releases ?? [])
+                               .map(({ lastModified }) => Date.parse(lastModified))
+                               .sort()
+                               .at(0) ?? null
+               },
+               /**
+                * App authors as comma separated string
+                */
+               appAuthors() {
+                       console.warn(this.app)
+                       if (!this.app) {
+                               return ''
+                       }
+
+                       const authorName = (xmlNode) => {
+                               if (xmlNode['@value']) {
+                                       // Complex node (with email or homepage attribute)
+                                       return xmlNode['@value']
+                               }
+                               // Simple text node
+                               return xmlNode
+                       }
+
+                       const authors = Array.isArray(this.app.author)
+                               ? this.app.author.map(authorName)
+                               : [authorName(this.app.author)]
+
+                       return authors
+                               .sort((a, b) => a.split(' ').at(-1).localeCompare(b.split(' ').at(-1)))
+                               .join(', ')
+               },
+
+               appstoreUrl() {
+                       return `https://apps.nextcloud.com/apps/${this.app.id}`
+               },
+
+               /**
+                * Further external resources (e.g. website)
+                */
+               externalResources() {
+                       const resources = []
+                       if (!this.app.internal) {
+                               resources.push({
+                                       id: 'appstore',
+                                       href: this.appstoreUrl,
+                                       label: t('settings', 'View in store'),
+                               })
+                       }
+                       if (this.app.website) {
+                               resources.push({
+                                       id: 'website',
+                                       href: this.app.website,
+                                       label: t('settings', 'Visit website'),
+                               })
+                       }
+                       if (this.app.documentation) {
+                               if (this.app.documentation.user) {
+                                       resources.push({
+                                               id: 'doc-user',
+                                               href: this.app.documentation.user,
+                                               label: t('settings', 'Usage documentation'),
+                                       })
+                               }
+                               if (this.app.documentation.admin) {
+                                       resources.push({
+                                               id: 'doc-admin',
+                                               href: this.app.documentation.admin,
+                                               label: t('settings', 'Admin documentation'),
+                                       })
+                               }
+                               if (this.app.documentation.developer) {
+                                       resources.push({
+                                               id: 'doc-developer',
+                                               href: this.app.documentation.developer,
+                                               label: t('settings', 'Developer documentation'),
+                                       })
+                               }
+                       }
+                       return resources
+               },
+
+               appCategories() {
+                       return [this.app.category].flat()
+                               .map((id) => this.store.getCategoryById(id)?.displayName ?? id)
+                               .join(', ')
+               },
+
+               rateAppUrl() {
+                       return `${this.appstoreUrl}#comments`
+               },
+               appGroups() {
+                       return this.app.groups.map(group => { return { id: group, name: group } })
+               },
+               groups() {
+                       return this.$store.getters.getGroups
+                               .filter(group => group.id !== 'disabled')
+                               .sort((a, b) => a.name.localeCompare(b.name))
+               },
+       },
+       mounted() {
+               if (this.app.groups.length > 0) {
+                       this.groupCheckedAppsData = true
+               }
+       },
+}
+</script>
+
+<style scoped lang="scss">
+.app-details {
+       padding: 20px;
+
+       &__actions {
+               // app management
+               &-manage {
+                       // if too many, shrink them and ellipsis
+                       display: flex;
+                       input {
+                               flex: 0 1 auto;
+                               min-width: 0;
+                               text-overflow: ellipsis;
+                               white-space: nowrap;
+                               overflow: hidden;
+                       }
+               }
+       }
+       &__authors {
+               color: var(--color-text-maxcontrast);
+       }
+
+       &__section {
+               margin-top: 15px;
+
+               h4 {
+                       font-size: 16px;
+                       font-weight: bold;
+                       margin-block-end: 5px;
+               }
+       }
+
+       &__interact {
+               display: flex;
+               flex-direction: row;
+               flex-wrap: wrap;
+               gap: 12px;
+       }
+
+       &__documentation {
+               a {
+                       text-decoration: underline;
+               }
+               li {
+                       padding-inline-start: 20px;
+
+                       &::before {
+                               width: 5px;
+                               height: 5px;
+                               border-radius: 100%;
+                               background-color: var(--color-main-text);
+                               content: "";
+                               float: inline-start;
+                               margin-inline-start: -13px;
+                               position: relative;
+                               top: 10px;
+                       }
+               }
+       }
+}
+
+.force {
+       color: var(--color-error);
+       border-color: var(--color-error);
+       background: var(--color-main-background);
+}
+.force:hover,
+.force:active {
+       color: var(--color-main-background);
+       border-color: var(--color-error) !important;
+       background: var(--color-error);
+}
+
+.missing-dependencies {
+       list-style: initial;
+       list-style-type: initial;
+       list-style-position: inside;
+}
+</style>
diff --git a/apps/settings/src/components/AppStore/AppStoreSidebar/AppReleasesTab.vue b/apps/settings/src/components/AppStore/AppStoreSidebar/AppReleasesTab.vue
new file mode 100644 (file)
index 0000000..ec9d3a6
--- /dev/null
@@ -0,0 +1,57 @@
+<!--
+  - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <NcAppSidebarTab v-if="hasChangelog"
+               id="changelog"
+               :name="t('settings', 'Changelog')"
+               :order="2">
+               <template #icon>
+                       <NcIconSvgWrapper :path="mdiClockFast" :size="24" />
+               </template>
+               <div v-for="release in app.releases" :key="release.version" class="app-sidebar-tabs__release">
+                       <h2>{{ release.version }}</h2>
+                       <Markdown class="app-sidebar-tabs__release-text"
+                               :text="createChangelogFromRelease(release)" />
+               </div>
+       </NcAppSidebarTab>
+</template>
+
+<script setup lang="ts">
+import type { IAppstoreApp, IAppstoreAppRelease } from '../../../app-types.ts'
+
+import { mdiClockFast } from '@mdi/js'
+import { getLanguage, translate as t } from '@nextcloud/l10n'
+import { computed } from 'vue'
+
+import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import Markdown from '../../Markdown.vue'
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const props = defineProps<{ app: IAppstoreApp }>()
+
+const hasChangelog = computed(() => Object.values(props.app.releases?.[0]?.translations ?? {}).some(({ changelog }) => !!changelog))
+
+const createChangelogFromRelease = (release: IAppstoreAppRelease) => release.translations?.[getLanguage()]?.changelog ?? release.translations?.en?.changelog ?? ''
+</script>
+
+<style scoped lang="scss">
+.app-sidebar-tabs__release {
+       h2 {
+               border-bottom: 1px solid var(--color-border);
+               font-size: 24px;
+       }
+
+       &-text {
+               // Overwrite changelog heading styles
+               :deep(h3) {
+                       font-size: 20px;
+               }
+               :deep(h4) {
+                       font-size: 17px;
+               }
+       }
+}
+</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/AppLink.vue b/apps/settings/src/components/AppStoreDiscover/AppLink.vue
deleted file mode 100644 (file)
index 703adb9..0000000
+++ /dev/null
@@ -1,98 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-<template>
-       <a v-if="linkProps" v-bind="linkProps">
-               <slot />
-       </a>
-       <RouterLink v-else-if="routerProps" v-bind="routerProps">
-               <slot />
-       </RouterLink>
-</template>
-
-<script lang="ts">
-import type { RouterLinkProps } from 'vue-router/types/router.js'
-
-import { loadState } from '@nextcloud/initial-state'
-import { generateUrl } from '@nextcloud/router'
-import { defineComponent } from 'vue'
-import { RouterLink } from 'vue-router'
-import type { INavigationEntry } from '../../../../../core/src/types/navigation'
-
-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
- */
-export default defineComponent({
-       name: 'AppLink',
-
-       components: { RouterLink },
-
-       props: {
-               href: {
-                       type: String,
-                       required: true,
-               },
-       },
-
-       data() {
-               return {
-                       routerProps: undefined as RouterLinkProps|undefined,
-                       linkProps: undefined as Record<string, string>|undefined,
-               }
-       },
-
-       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
-                               }
-
-                               // Fallback to show the app store entry
-                               this.routerProps = {
-                                       to: {
-                                               name: 'apps-details',
-                                               params: {
-                                                       category: this.$route.params?.category ?? 'discover',
-                                                       id: appId,
-                                               },
-                                       },
-                               }
-                       },
-               },
-       },
-})
-</script>
diff --git a/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue
deleted file mode 100644 (file)
index d0a7811..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-<!--
-  - 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/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'
-import { parseApiResponse, filterElements } from '../../utils/appDiscoverParser.ts'
-
-const PostType = defineAsyncComponent(() => import('./PostType.vue'))
-const CarouselType = defineAsyncComponent(() => import('./CarouselType.vue'))
-const ShowcaseType = defineAsyncComponent(() => import('./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>
diff --git a/apps/settings/src/components/AppStoreDiscover/AppType.vue b/apps/settings/src/components/AppStoreDiscover/AppType.vue
deleted file mode 100644 (file)
index 7263dc7..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-<template>
-       <AppItem v-if="app"
-               :app="app"
-               category="discover"
-               class="app-discover-app"
-               inline
-               :list-view="false" />
-       <a v-else
-               class="app-discover-app app-discover-app__skeleton"
-               :href="appStoreLink"
-               target="_blank"
-               :title="modelValue.appId"
-               rel="noopener noreferrer">
-               <!-- This is a fallback skeleton -->
-               <span class="skeleton-element" />
-               <span class="skeleton-element" />
-               <span class="skeleton-element" />
-               <span class="skeleton-element" />
-               <span class="skeleton-element" />
-       </a>
-</template>
-
-<script setup lang="ts">
-import type { IAppDiscoverApp } from '../../constants/AppDiscoverTypes'
-
-import { computed } from 'vue'
-import { useAppsStore } from '../../store/apps-store.ts'
-
-import AppItem from '../AppList/AppItem.vue'
-
-const props = defineProps<{
-       modelValue: IAppDiscoverApp
-}>()
-
-const store = useAppsStore()
-const app = computed(() => store.getAppById(props.modelValue.appId))
-
-const appStoreLink = computed(() => props.modelValue.appId ? `https://apps.nextcloud.com/apps/${props.modelValue.appId}` : '#')
-</script>
-
-<style scoped lang="scss">
-.app-discover-app {
-       width: 100% !important; // full with of the showcase item
-
-       &:hover {
-               background: var(--color-background-hover);
-               border-radius: var(--border-radius-rounded);
-       }
-
-       &__skeleton {
-               display: flex;
-               flex-direction: column;
-               gap: 8px;
-
-               padding: 30px; // Same as AppItem
-
-               > :first-child {
-                       height: 50%;
-                       min-height: 130px;
-               }
-
-               > :nth-child(2) {
-                       width: 50px;
-               }
-
-               > :nth-child(5) {
-                       height: 20px;
-                       width: 100px;
-               }
-
-               > :not(:first-child) {
-                       border-radius: 4px;
-               }
-       }
-}
-
-.skeleton-element {
-       min-height: var(--default-font-size, 15px);
-
-       background: linear-gradient(90deg, var(--color-background-dark), var(--color-background-darker), var(--color-background-dark));
-       background-size: 400% 400%;
-       animation: gradient 6s ease infinite;
-}
-
-@keyframes gradient {
-       0% {
-               background-position: 0% 50%;
-       }
-       50% {
-               background-position: 100% 50%;
-       }
-       100% {
-               background-position: 0% 50%;
-       }
-}
-</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/CarouselType.vue b/apps/settings/src/components/AppStoreDiscover/CarouselType.vue
deleted file mode 100644 (file)
index e657c7a..0000000
+++ /dev/null
@@ -1,206 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-<template>
-       <section :aria-roledescription="t('settings', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
-               <h3 v-if="headline" :id="headingId">
-                       {{ translatedHeadline }}
-               </h3>
-               <div class="app-discover-carousel__wrapper">
-                       <div class="app-discover-carousel__button-wrapper">
-                               <NcButton class="app-discover-carousel__button app-discover-carousel__button--previous"
-                                       type="tertiary-no-background"
-                                       :aria-label="t('settings', 'Previous slide')"
-                                       :disabled="!hasPrevious"
-                                       @click="currentIndex -= 1">
-                                       <template #icon>
-                                               <NcIconSvgWrapper :path="mdiChevronLeft" />
-                                       </template>
-                               </NcButton>
-                       </div>
-
-                       <Transition :name="transitionName" mode="out-in">
-                               <PostType v-bind="shownElement"
-                                       :key="shownElement.id ?? currentIndex"
-                                       :aria-labelledby="`${internalId}-tab-${currentIndex}`"
-                                       :dom-id="`${internalId}-tabpanel-${currentIndex}`"
-                                       inline
-                                       role="tabpanel" />
-                       </Transition>
-
-                       <div class="app-discover-carousel__button-wrapper">
-                               <NcButton class="app-discover-carousel__button app-discover-carousel__button--next"
-                                       type="tertiary-no-background"
-                                       :aria-label="t('settings', 'Next slide')"
-                                       :disabled="!hasNext"
-                                       @click="currentIndex += 1">
-                                       <template #icon>
-                                               <NcIconSvgWrapper :path="mdiChevronRight" />
-                                       </template>
-                               </NcButton>
-                       </div>
-               </div>
-               <div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('settings', 'Choose slide to display')">
-                       <NcButton v-for="index of content.length"
-                               :id="`${internalId}-tab-${index}`"
-                               :key="index"
-                               :aria-label="t('settings', '{index} of {total}', { index, total: content.length })"
-                               :aria-controls="`${internalId}-tabpanel-${index}`"
-                               :aria-selected="`${currentIndex === (index - 1)}`"
-                               role="tab"
-                               type="tertiary-no-background"
-                               @click="currentIndex = index - 1">
-                               <template #icon>
-                                       <NcIconSvgWrapper :path="currentIndex === (index - 1) ? mdiCircleSlice8 : mdiCircleOutline" />
-                               </template>
-                       </NcButton>
-               </div>
-       </section>
-</template>
-
-<script lang="ts">
-import type { PropType } from 'vue'
-import type { IAppDiscoverCarousel } from '../../constants/AppDiscoverTypes.ts'
-
-import { mdiChevronLeft, mdiChevronRight, mdiCircleOutline, mdiCircleSlice8 } from '@mdi/js'
-import { translate as t } from '@nextcloud/l10n'
-import { computed, defineComponent, nextTick, ref, watch } from 'vue'
-import { commonAppDiscoverProps } from './common.ts'
-import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
-
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import PostType from './PostType.vue'
-
-export default defineComponent({
-       name: 'CarouselType',
-
-       components: {
-               NcButton,
-               NcIconSvgWrapper,
-               PostType,
-       },
-
-       props: {
-               ...commonAppDiscoverProps,
-
-               /**
-                * The content of the carousel
-                */
-               content: {
-                       type: Array as PropType<IAppDiscoverCarousel['content']>,
-                       required: true,
-               },
-       },
-
-       setup(props) {
-               const translatedHeadline = useLocalizedValue(computed(() => props.headline))
-
-               const currentIndex = ref(Math.min(1, props.content.length - 1))
-               const shownElement = ref(props.content[currentIndex.value])
-               const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
-               const hasPrevious = computed(() => currentIndex.value > 0)
-
-               const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
-               const headingId = computed(() => `${internalId.value}-h`)
-
-               const transitionName = ref('slide-in')
-               watch(() => currentIndex.value, (o, n) => {
-                       if (o < n) {
-                               transitionName.value = 'slide-in'
-                       } else {
-                               transitionName.value = 'slide-out'
-                       }
-
-                       // Wait next tick
-                       nextTick(() => {
-                               shownElement.value = props.content[currentIndex.value]
-                       })
-               })
-
-               return {
-                       t,
-                       internalId,
-                       headingId,
-
-                       hasNext,
-                       hasPrevious,
-                       currentIndex,
-                       shownElement,
-
-                       transitionName,
-
-                       translatedHeadline,
-
-                       mdiChevronLeft,
-                       mdiChevronRight,
-                       mdiCircleOutline,
-                       mdiCircleSlice8,
-               }
-       },
-})
-</script>
-
-<style scoped lang="scss">
-h3 {
-       font-size: 24px;
-       font-weight: 600;
-       margin-block: 0 1em;
-}
-
-.app-discover-carousel {
-       &__wrapper {
-               display: flex;
-       }
-
-       &__button {
-               color: var(--color-text-maxcontrast);
-               position: absolute;
-               top: calc(50% - 22px); // 50% minus half of button height
-
-               &-wrapper {
-                       position: relative;
-               }
-
-               // See padding of discover section
-               &--next {
-                       inset-inline-end: -54px;
-               }
-               &--previous {
-                       inset-inline-start: -54px;
-               }
-       }
-
-       &__tabs {
-               display: flex;
-               flex-direction: row;
-               justify-content: center;
-
-               > * {
-                       color: var(--color-text-maxcontrast);
-               }
-       }
-}
-</style>
-
-<style>
-.slide-in-enter-active,
-.slide-in-leave-active,
-.slide-out-enter-active,
-.slide-out-leave-active {
-  transition: all .4s ease-out;
-}
-
-.slide-in-leave-to,
-.slide-out-enter {
-  opacity: 0;
-  transform: translateX(50%);
-}
-
-.slide-in-enter,
-.slide-out-leave-to {
-  opacity: 0;
-  transform: translateX(-50%);
-}
-</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/PostType.vue b/apps/settings/src/components/AppStoreDiscover/PostType.vue
deleted file mode 100644 (file)
index fbd7079..0000000
+++ /dev/null
@@ -1,299 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-<template>
-       <article :id="domId"
-               ref="container"
-               class="app-discover-post"
-               :class="{
-                       'app-discover-post--reverse': media && media.alignment === 'start',
-                       'app-discover-post--small': isSmallWidth
-               }">
-               <component :is="link ? 'AppLink' : 'div'"
-                       v-if="headline || text"
-                       :href="link"
-                       class="app-discover-post__text">
-                       <component :is="inline ? 'h4' : 'h3'">
-                               {{ translatedHeadline }}
-                       </component>
-                       <p>{{ translatedText }}</p>
-               </component>
-               <component :is="mediaLink ? 'AppLink' : 'div'"
-                       v-if="mediaSources"
-                       :href="mediaLink"
-                       class="app-discover-post__media"
-                       :class="{
-                               'app-discover-post__media--fullwidth': isFullWidth,
-                               'app-discover-post__media--start': media?.alignment === 'start',
-                               'app-discover-post__media--end': media?.alignment === 'end',
-                       }">
-                       <component :is="isImage ? 'picture' : 'video'"
-                               ref="mediaElement"
-                               class="app-discover-post__media-element"
-                               :muted="!isImage"
-                               :playsinline="!isImage"
-                               :preload="!isImage && 'auto'"
-                               @ended="hasPlaybackEnded = true">
-                               <source v-for="source of mediaSources"
-                                       :key="source.src"
-                                       :src="isImage ? undefined : generatePrivacyUrl(source.src)"
-                                       :srcset="isImage ? generatePrivacyUrl(source.src) : undefined"
-                                       :type="source.mime">
-                               <img v-if="isImage"
-                                       :src="generatePrivacyUrl(mediaSources[0].src)"
-                                       :alt="mediaAlt">
-                       </component>
-                       <div class="app-discover-post__play-icon-wrapper">
-                               <NcIconSvgWrapper v-if="!isImage && showPlayVideo"
-                                       class="app-discover-post__play-icon"
-                                       :path="mdiPlayCircleOutline"
-                                       :size="92" />
-                       </div>
-               </component>
-       </article>
-</template>
-
-<script lang="ts">
-import type { IAppDiscoverPost } from '../../constants/AppDiscoverTypes.ts'
-import type { PropType } from 'vue'
-
-import { mdiPlayCircleOutline } from '@mdi/js'
-import { generateUrl } from '@nextcloud/router'
-import { useElementSize, useElementVisibility } from '@vueuse/core'
-import { computed, defineComponent, ref, watchEffect } from 'vue'
-import { commonAppDiscoverProps } from './common'
-import { useLocalizedValue } from '../../composables/useGetLocalizedValue'
-
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import AppLink from './AppLink.vue'
-
-export default defineComponent({
-       components: {
-               AppLink,
-               NcIconSvgWrapper,
-       },
-
-       props: {
-               ...commonAppDiscoverProps,
-
-               text: {
-                       type: Object as PropType<IAppDiscoverPost['text']>,
-                       required: false,
-                       default: () => null,
-               },
-
-               media: {
-                       type: Object as PropType<IAppDiscoverPost['media']>,
-                       required: false,
-                       default: () => null,
-               },
-
-               inline: {
-                       type: Boolean,
-                       required: false,
-                       default: false,
-               },
-
-               domId: {
-                       type: String,
-                       required: false,
-                       default: null,
-               },
-       },
-
-       setup(props) {
-               const translatedHeadline = useLocalizedValue(computed(() => props.headline))
-               const translatedText = useLocalizedValue(computed(() => props.text))
-               const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
-
-               const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
-               const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
-
-               const isImage = computed(() => mediaSources?.value?.[0].mime.startsWith('image/') === true)
-               /**
-                * Is the media is shown full width
-                */
-               const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
-
-               /**
-                * Link on the media
-                * Fallback to post link to prevent link inside link (which is invalid HTML)
-                */
-               const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
-
-               const hasPlaybackEnded = ref(false)
-               const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
-
-               /**
-                * The content is sized / styles are applied based on the container width
-                * To make it responsive even for inline usage and when opening / closing the sidebar / navigation
-                */
-               const container = ref<HTMLElement>()
-               const { width: containerWidth } = useElementSize(container)
-               const isSmallWidth = computed(() => containerWidth.value < 600)
-
-               /**
-                * Generate URL for cached media to prevent user can be tracked
-                * @param url The URL to resolve
-                */
-               const generatePrivacyUrl = (url: string) => url.startsWith('/') ? url : generateUrl('/settings/api/apps/media?fileName={fileName}', { fileName: url })
-
-               const mediaElement = ref<HTMLVideoElement|HTMLPictureElement>()
-               const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
-               watchEffect(() => {
-                       // Only if media is video
-                       if (!isImage.value && mediaElement.value) {
-                               const video = mediaElement.value as HTMLVideoElement
-
-                               if (mediaIsVisible.value) {
-                                       // Ensure video is muted - otherwise .play() will be blocked by browsers
-                                       video.muted = true
-                                       // If visible start playback
-                                       video.play()
-                               } else {
-                                       // If not visible pause the playback
-                                       video.pause()
-                                       // If the animation has ended reset
-                                       if (video.ended) {
-                                               video.currentTime = 0
-                                               hasPlaybackEnded.value = false
-                                       }
-                               }
-                       }
-               })
-
-               return {
-                       mdiPlayCircleOutline,
-
-                       container,
-
-                       translatedText,
-                       translatedHeadline,
-                       mediaElement,
-                       mediaSources,
-                       mediaAlt,
-                       mediaLink,
-
-                       hasPlaybackEnded,
-                       showPlayVideo,
-
-                       isFullWidth,
-                       isSmallWidth,
-                       isImage,
-
-                       generatePrivacyUrl,
-               }
-       },
-})
-</script>
-
-<style scoped lang="scss">
-.app-discover-post {
-       max-height: 300px;
-       width: 100%;
-       background-color: var(--color-primary-element-light);
-       border-radius: var(--border-radius-rounded);
-
-       display: flex;
-       flex-direction: row;
-       justify-content: start;
-
-       &--reverse {
-               flex-direction: row-reverse;
-       }
-
-       h3, h4 {
-               font-size: 24px;
-               font-weight: 600;
-               margin-block: 0 1em;
-       }
-
-       &__text {
-               display: block;
-               width: 100%;
-               padding: var(--border-radius-rounded);
-               overflow-y: scroll;
-       }
-
-       // If there is media next to the text we do not want a padding on the bottom as this looks weird when scrolling
-       &:has(&__media) &__text {
-               padding-block-end: 0;
-       }
-
-       &__media {
-               display: block;
-               overflow: hidden;
-
-               max-width: 450px;
-               border-radius: var(--border-radius-rounded);
-
-               &--fullwidth {
-                       max-width: unset;
-                       max-height: unset;
-               }
-
-               &--end {
-                       border-end-start-radius: 0;
-                       border-start-start-radius: 0;
-               }
-
-               &--start {
-                       border-end-end-radius: 0;
-                       border-start-end-radius: 0;
-               }
-
-               img, &-element {
-                       height: 100%;
-                       width: 100%;
-                       object-fit: cover;
-                       object-position: center;
-               }
-       }
-
-       &__play-icon {
-               position: absolute;
-               top: -46px; // half of the icon height
-               inset-inline-end: -46px; // half of the icon width
-
-               &-wrapper {
-                       position: relative;
-                       top: -50%;
-                       inset-inline-start: -50%;
-               }
-       }
-}
-
-.app-discover-post--small {
-       &.app-discover-post {
-               flex-direction: column;
-               max-height: 500px;
-
-               &--reverse {
-                       flex-direction: column-reverse;
-               }
-       }
-
-       .app-discover-post {
-               &__text {
-                       flex: 1 1 50%;
-               }
-
-               &__media {
-                       min-width: 100%;
-
-                       &--end {
-                               border-radius: var(--border-radius-rounded);
-                               border-start-end-radius: 0;
-                               border-start-start-radius: 0;
-                       }
-
-                       &--start {
-                               border-radius: var(--border-radius-rounded);
-                               border-end-end-radius: 0;
-                               border-end-start-radius: 0;
-                       }
-               }
-       }
-}
-</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue b/apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue
deleted file mode 100644 (file)
index ac057b9..0000000
+++ /dev/null
@@ -1,122 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-<template>
-       <section ref="container"
-               class="app-discover-showcase"
-               :class="{
-                       'app-discover-showcase--small': isSmallWidth,
-                       'app-discover-showcase--extra-small': isExtraSmallWidth,
-               }">
-               <h3 v-if="translatedHeadline">
-                       {{ translatedHeadline }}
-               </h3>
-               <ul class="app-discover-showcase__list">
-                       <li v-for="(item, index) of content"
-                               :key="item.id ?? index"
-                               class="app-discover-showcase__item">
-                               <PostType v-if="item.type === 'post'"
-                                       v-bind="item"
-                                       inline />
-                               <AppType v-else-if="item.type === 'app'" :model-value="item" />
-                       </li>
-               </ul>
-       </section>
-</template>
-
-<script lang="ts">
-import type { PropType } from 'vue'
-import type { IAppDiscoverShowcase } from '../../constants/AppDiscoverTypes.ts'
-
-import { translate as t } from '@nextcloud/l10n'
-import { useElementSize } from '@vueuse/core'
-import { computed, defineComponent, ref } from 'vue'
-import { commonAppDiscoverProps } from './common.ts'
-import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
-
-import AppType from './AppType.vue'
-import PostType from './PostType.vue'
-
-export default defineComponent({
-       name: 'ShowcaseType',
-
-       components: {
-               AppType,
-               PostType,
-       },
-
-       props: {
-               ...commonAppDiscoverProps,
-
-               /**
-                * The content of the carousel
-                */
-               content: {
-                       type: Array as PropType<IAppDiscoverShowcase['content']>,
-                       required: true,
-               },
-       },
-
-       setup(props) {
-               const translatedHeadline = useLocalizedValue(computed(() => props.headline))
-
-               /**
-                * Make the element responsive based on the container width to also handle open navigation or sidebar
-                */
-               const container = ref<HTMLElement>()
-               const { width: containerWidth } = useElementSize(container)
-               const isSmallWidth = computed(() => containerWidth.value < 768)
-               const isExtraSmallWidth = computed(() => containerWidth.value < 512)
-
-               return {
-                       t,
-
-                       container,
-                       isSmallWidth,
-                       isExtraSmallWidth,
-                       translatedHeadline,
-               }
-       },
-})
-</script>
-
-<style scoped lang="scss">
-$item-gap: calc(var(--default-clickable-area, 44px) / 2);
-
-h3 {
-       font-size: 24px;
-       font-weight: 600;
-       margin-block: 0 1em;
-}
-
-.app-discover-showcase {
-       &__list {
-               list-style: none;
-
-               display: flex;
-               flex-wrap: wrap;
-               gap: $item-gap;
-       }
-
-       &__item {
-               display: flex;
-               align-items: stretch;
-
-               position: relative;
-               width: calc(33% - $item-gap);
-       }
-}
-
-.app-discover-showcase--small {
-       .app-discover-showcase__item {
-               width: calc(50% - $item-gap);
-       }
-}
-
-.app-discover-showcase--extra-small {
-       .app-discover-showcase__item {
-               width: 100%;
-       }
-}
-</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/common.ts b/apps/settings/src/components/AppStoreDiscover/common.ts
deleted file mode 100644 (file)
index 277d491..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * 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'
-
-/**
- * Common Props for all app discover types
- */
-export const commonAppDiscoverProps = {
-       type: {
-               type: String as PropType<IAppDiscoverElement['type']>,
-               required: true,
-               validator: (v: unknown) => typeof v === 'string' && APP_DISCOVER_KNOWN_TYPES.includes(v as never),
-       },
-
-       id: {
-               type: String as PropType<IAppDiscoverElement['id']>,
-               required: true,
-       },
-
-       date: {
-               type: Number as PropType<IAppDiscoverElement['date']>,
-               required: false,
-               default: undefined,
-       },
-
-       expiryDate: {
-               type: Number as PropType<IAppDiscoverElement['expiryDate']>,
-               required: false,
-               default: undefined,
-       },
-
-       headline: {
-               type: Object as PropType<IAppDiscoverElement['headline']>,
-               required: false,
-               default: () => null,
-       },
-
-       link: {
-               type: String as PropType<IAppDiscoverElement['link']>,
-               required: false,
-               default: () => null,
-       },
-} as const
diff --git a/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue
deleted file mode 100644 (file)
index 36c551e..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-
-<template>
-       <NcAppSidebarTab id="desc"
-               :name="t('settings', 'Description')"
-               :order="0">
-               <template #icon>
-                       <NcIconSvgWrapper :path="mdiTextShort" />
-               </template>
-               <div class="app-description">
-                       <Markdown :text="app.description" :min-heading="4" />
-               </div>
-       </NcAppSidebarTab>
-</template>
-
-<script setup lang="ts">
-import type { IAppstoreApp } from '../../app-types'
-
-import { mdiTextShort } from '@mdi/js'
-import { translate as t } from '@nextcloud/l10n'
-
-import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import Markdown from '../Markdown.vue'
-
-defineProps<{
-       app: IAppstoreApp,
-}>()
-</script>
-
-<style scoped lang="scss">
-.app-description {
-       padding: 12px;
-}
-</style>
diff --git a/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue
deleted file mode 100644 (file)
index 653a1ee..0000000
+++ /dev/null
@@ -1,417 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-
-<template>
-       <NcAppSidebarTab id="details"
-               :name="t('settings', 'Details')"
-               :order="1">
-               <template #icon>
-                       <NcIconSvgWrapper :path="mdiTextBox" />
-               </template>
-               <div class="app-details">
-                       <div class="app-details__actions">
-                               <div v-if="app.active && canLimitToGroups(app)" class="app-details__actions-groups">
-                                       <input :id="`groups_enable_${app.id}`"
-                                               v-model="groupCheckedAppsData"
-                                               type="checkbox"
-                                               :value="app.id"
-                                               class="groups-enable__checkbox checkbox"
-                                               @change="setGroupLimit">
-                                       <label :for="`groups_enable_${app.id}`">{{ t('settings', 'Limit to groups') }}</label>
-                                       <input type="hidden"
-                                               class="group_select"
-                                               :title="t('settings', 'All')"
-                                               value="">
-                                       <br>
-                                       <label for="limitToGroups">
-                                               <span>{{ t('settings', 'Limit app usage to groups') }}</span>
-                                       </label>
-                                       <NcSelect v-if="isLimitedToGroups(app)"
-                                               input-id="limitToGroups"
-                                               :options="groups"
-                                               :value="appGroups"
-                                               :limit="5"
-                                               label="name"
-                                               :multiple="true"
-                                               :close-on-select="false"
-                                               @option:selected="addGroupLimitation"
-                                               @option:deselected="removeGroupLimitation"
-                                               @search="asyncFindGroup">
-                                               <span slot="noResult">{{ t('settings', 'No results') }}</span>
-                                       </NcSelect>
-                               </div>
-                               <div class="app-details__actions-manage">
-                                       <input v-if="app.update"
-                                               class="update primary"
-                                               type="button"
-                                               :value="t('settings', 'Update to {version}', { version: app.update })"
-                                               :disabled="installing || isLoading"
-                                               @click="update(app.id)">
-                                       <input v-if="app.canUnInstall"
-                                               class="uninstall"
-                                               type="button"
-                                               :value="t('settings', 'Remove')"
-                                               :disabled="installing || isLoading"
-                                               @click="remove(app.id)">
-                                       <input v-if="app.active"
-                                               class="enable"
-                                               type="button"
-                                               :value="t('settings','Disable')"
-                                               :disabled="installing || isLoading"
-                                               @click="disable(app.id)">
-                                       <input v-if="!app.active && (app.canInstall || app.isCompatible)"
-                                               :title="enableButtonTooltip"
-                                               :aria-label="enableButtonTooltip"
-                                               class="enable primary"
-                                               type="button"
-                                               :value="enableButtonText"
-                                               :disabled="!app.canInstall || installing || isLoading"
-                                               @click="enable(app.id)">
-                                       <input v-else-if="!app.active && !app.canInstall"
-                                               :title="forceEnableButtonTooltip"
-                                               :aria-label="forceEnableButtonTooltip"
-                                               class="enable force"
-                                               type="button"
-                                               :value="forceEnableButtonText"
-                                               :disabled="installing || isLoading"
-                                               @click="forceEnable(app.id)">
-                               </div>
-                       </div>
-
-                       <ul class="app-details__dependencies">
-                               <li v-if="app.missingMinOwnCloudVersion">
-                                       {{ t('settings', 'This app has no minimum Nextcloud version assigned. This will be an error in the future.') }}
-                               </li>
-                               <li v-if="app.missingMaxOwnCloudVersion">
-                                       {{ t('settings', 'This app has no maximum Nextcloud version assigned. This will be an error in the future.') }}
-                               </li>
-                               <li v-if="!app.canInstall">
-                                       {{ t('settings', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
-                                       <ul class="missing-dependencies">
-                                               <li v-for="(dep, index) in app.missingDependencies" :key="index">
-                                                       {{ dep }}
-                                               </li>
-                                       </ul>
-                               </li>
-                       </ul>
-
-                       <div v-if="lastModified && !app.shipped" class="app-details__section">
-                               <h4>
-                                       {{ t('settings', 'Latest updated') }}
-                               </h4>
-                               <NcDateTime :timestamp="lastModified" />
-                       </div>
-
-                       <div class="app-details__section">
-                               <h4>
-                                       {{ t('settings', 'Author') }}
-                               </h4>
-                               <p class="app-details__authors">
-                                       {{ appAuthors }}
-                               </p>
-                       </div>
-
-                       <div class="app-details__section">
-                               <h4>
-                                       {{ t('settings', 'Categories') }}
-                               </h4>
-                               <p>
-                                       {{ appCategories }}
-                               </p>
-                       </div>
-
-                       <div v-if="externalResources.length > 0" class="app-details__section">
-                               <h4>{{ t('settings', 'Resources') }}</h4>
-                               <ul class="app-details__documentation" :aria-label="t('settings', 'Documentation')">
-                                       <li v-for="resource of externalResources" :key="resource.id">
-                                               <a class="appslink"
-                                                       :href="resource.href"
-                                                       target="_blank"
-                                                       rel="noreferrer noopener">
-                                                       {{ resource.label }} â†—
-                                               </a>
-                                       </li>
-                               </ul>
-                       </div>
-
-                       <div class="app-details__section">
-                               <h4>{{ t('settings', 'Interact') }}</h4>
-                               <div class="app-details__interact">
-                                       <NcButton :disabled="!app.bugs"
-                                               :href="app.bugs ?? '#'"
-                                               :aria-label="t('settings', 'Report a bug')"
-                                               :title="t('settings', 'Report a bug')">
-                                               <template #icon>
-                                                       <NcIconSvgWrapper :path="mdiBug" />
-                                               </template>
-                                       </NcButton>
-                                       <NcButton :disabled="!app.bugs"
-                                               :href="app.bugs ?? '#'"
-                                               :aria-label="t('settings', 'Request feature')"
-                                               :title="t('settings', 'Request feature')">
-                                               <template #icon>
-                                                       <NcIconSvgWrapper :path="mdiFeatureSearch" />
-                                               </template>
-                                       </NcButton>
-                                       <NcButton v-if="app.appstoreData?.discussion"
-                                               :href="app.appstoreData.discussion"
-                                               :aria-label="t('settings', 'Ask questions or discuss')"
-                                               :title="t('settings', 'Ask questions or discuss')">
-                                               <template #icon>
-                                                       <NcIconSvgWrapper :path="mdiTooltipQuestion" />
-                                               </template>
-                                       </NcButton>
-                                       <NcButton v-if="!app.internal"
-                                               :href="rateAppUrl"
-                                               :aria-label="t('settings', 'Rate the app')"
-                                               :title="t('settings', 'Rate')">
-                                               <template #icon>
-                                                       <NcIconSvgWrapper :path="mdiStar" />
-                                               </template>
-                                       </NcButton>
-                               </div>
-                       </div>
-               </div>
-       </NcAppSidebarTab>
-</template>
-
-<script>
-import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-
-import AppManagement from '../../mixins/AppManagement.js'
-import { mdiBug, mdiFeatureSearch, mdiStar, mdiTextBox, mdiTooltipQuestion } from '@mdi/js'
-import { useAppsStore } from '../../store/apps-store'
-
-export default {
-       name: 'AppDetailsTab',
-
-       components: {
-               NcAppSidebarTab,
-               NcButton,
-               NcDateTime,
-               NcIconSvgWrapper,
-               NcSelect,
-       },
-       mixins: [AppManagement],
-
-       props: {
-               app: {
-                       type: Object,
-                       required: true,
-               },
-       },
-
-       setup() {
-               const store = useAppsStore()
-
-               return {
-                       store,
-
-                       mdiBug,
-                       mdiFeatureSearch,
-                       mdiStar,
-                       mdiTextBox,
-                       mdiTooltipQuestion,
-               }
-       },
-
-       data() {
-               return {
-                       groupCheckedAppsData: false,
-               }
-       },
-
-       computed: {
-               lastModified() {
-                       return (this.app.appstoreData?.releases ?? [])
-                               .map(({ lastModified }) => Date.parse(lastModified))
-                               .sort()
-                               .at(0) ?? null
-               },
-               /**
-                * App authors as comma separated string
-                */
-               appAuthors() {
-                       console.warn(this.app)
-                       if (!this.app) {
-                               return ''
-                       }
-
-                       const authorName = (xmlNode) => {
-                               if (xmlNode['@value']) {
-                                       // Complex node (with email or homepage attribute)
-                                       return xmlNode['@value']
-                               }
-                               // Simple text node
-                               return xmlNode
-                       }
-
-                       const authors = Array.isArray(this.app.author)
-                               ? this.app.author.map(authorName)
-                               : [authorName(this.app.author)]
-
-                       return authors
-                               .sort((a, b) => a.split(' ').at(-1).localeCompare(b.split(' ').at(-1)))
-                               .join(', ')
-               },
-
-               appstoreUrl() {
-                       return `https://apps.nextcloud.com/apps/${this.app.id}`
-               },
-
-               /**
-                * Further external resources (e.g. website)
-                */
-               externalResources() {
-                       const resources = []
-                       if (!this.app.internal) {
-                               resources.push({
-                                       id: 'appstore',
-                                       href: this.appstoreUrl,
-                                       label: t('settings', 'View in store'),
-                               })
-                       }
-                       if (this.app.website) {
-                               resources.push({
-                                       id: 'website',
-                                       href: this.app.website,
-                                       label: t('settings', 'Visit website'),
-                               })
-                       }
-                       if (this.app.documentation) {
-                               if (this.app.documentation.user) {
-                                       resources.push({
-                                               id: 'doc-user',
-                                               href: this.app.documentation.user,
-                                               label: t('settings', 'Usage documentation'),
-                                       })
-                               }
-                               if (this.app.documentation.admin) {
-                                       resources.push({
-                                               id: 'doc-admin',
-                                               href: this.app.documentation.admin,
-                                               label: t('settings', 'Admin documentation'),
-                                       })
-                               }
-                               if (this.app.documentation.developer) {
-                                       resources.push({
-                                               id: 'doc-developer',
-                                               href: this.app.documentation.developer,
-                                               label: t('settings', 'Developer documentation'),
-                                       })
-                               }
-                       }
-                       return resources
-               },
-
-               appCategories() {
-                       return [this.app.category].flat()
-                               .map((id) => this.store.getCategoryById(id)?.displayName ?? id)
-                               .join(', ')
-               },
-
-               rateAppUrl() {
-                       return `${this.appstoreUrl}#comments`
-               },
-               appGroups() {
-                       return this.app.groups.map(group => { return { id: group, name: group } })
-               },
-               groups() {
-                       return this.$store.getters.getGroups
-                               .filter(group => group.id !== 'disabled')
-                               .sort((a, b) => a.name.localeCompare(b.name))
-               },
-       },
-       mounted() {
-               if (this.app.groups.length > 0) {
-                       this.groupCheckedAppsData = true
-               }
-       },
-}
-</script>
-
-<style scoped lang="scss">
-.app-details {
-       padding: 20px;
-
-       &__actions {
-               // app management
-               &-manage {
-                       // if too many, shrink them and ellipsis
-                       display: flex;
-                       input {
-                               flex: 0 1 auto;
-                               min-width: 0;
-                               text-overflow: ellipsis;
-                               white-space: nowrap;
-                               overflow: hidden;
-                       }
-               }
-       }
-       &__authors {
-               color: var(--color-text-maxcontrast);
-       }
-
-       &__section {
-               margin-top: 15px;
-
-               h4 {
-                       font-size: 16px;
-                       font-weight: bold;
-                       margin-block-end: 5px;
-               }
-       }
-
-       &__interact {
-               display: flex;
-               flex-direction: row;
-               flex-wrap: wrap;
-               gap: 12px;
-       }
-
-       &__documentation {
-               a {
-                       text-decoration: underline;
-               }
-               li {
-                       padding-inline-start: 20px;
-
-                       &::before {
-                               width: 5px;
-                               height: 5px;
-                               border-radius: 100%;
-                               background-color: var(--color-main-text);
-                               content: "";
-                               float: inline-start;
-                               margin-inline-start: -13px;
-                               position: relative;
-                               top: 10px;
-                       }
-               }
-       }
-}
-
-.force {
-       color: var(--color-error);
-       border-color: var(--color-error);
-       background: var(--color-main-background);
-}
-.force:hover,
-.force:active {
-       color: var(--color-main-background);
-       border-color: var(--color-error) !important;
-       background: var(--color-error);
-}
-
-.missing-dependencies {
-       list-style: initial;
-       list-style-type: initial;
-       list-style-position: inside;
-}
-</style>
diff --git a/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue b/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue
deleted file mode 100644 (file)
index 68c5cb8..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-<template>
-       <NcAppSidebarTab v-if="hasChangelog"
-               id="changelog"
-               :name="t('settings', 'Changelog')"
-               :order="2">
-               <template #icon>
-                       <NcIconSvgWrapper :path="mdiClockFast" :size="24" />
-               </template>
-               <div v-for="release in app.releases" :key="release.version" class="app-sidebar-tabs__release">
-                       <h2>{{ release.version }}</h2>
-                       <Markdown class="app-sidebar-tabs__release-text"
-                               :text="createChangelogFromRelease(release)" />
-               </div>
-       </NcAppSidebarTab>
-</template>
-
-<script setup lang="ts">
-import type { IAppstoreApp, IAppstoreAppRelease } from '../../app-types.ts'
-
-import { mdiClockFast } from '@mdi/js'
-import { getLanguage, translate as t } from '@nextcloud/l10n'
-import { computed } from 'vue'
-
-import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import Markdown from '../Markdown.vue'
-
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-const props = defineProps<{ app: IAppstoreApp }>()
-
-const hasChangelog = computed(() => Object.values(props.app.releases?.[0]?.translations ?? {}).some(({ changelog }) => !!changelog))
-
-const createChangelogFromRelease = (release: IAppstoreAppRelease) => release.translations?.[getLanguage()]?.changelog ?? release.translations?.en?.changelog ?? ''
-</script>
-
-<style scoped lang="scss">
-.app-sidebar-tabs__release {
-       h2 {
-               border-bottom: 1px solid var(--color-border);
-               font-size: 24px;
-       }
-
-       &-text {
-               // Overwrite changelog heading styles
-               :deep(h3) {
-                       font-size: 20px;
-               }
-               :deep(h4) {
-                       font-size: 17px;
-               }
-       }
-}
-</style>
diff --git a/apps/settings/src/constants/AppDiscoverTypes.ts b/apps/settings/src/constants/AppDiscoverTypes.ts
deleted file mode 100644 (file)
index bc2736e..0000000
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-/**
- * Currently known types of app discover section elements
- */
-export const APP_DISCOVER_KNOWN_TYPES = ['post', 'showcase', 'carousel'] as const
-
-/**
- * Helper for localized values
- */
-export type ILocalizedValue<T> = Record<string, T | undefined> & { en: T }
-
-export interface IAppDiscoverElement {
-       /**
-        * Type of the element
-        */
-       type: typeof APP_DISCOVER_KNOWN_TYPES[number]
-
-       /**
-        * Identifier for this element
-        */
-       id: string,
-
-       /**
-        * Order of this element to pin elements (smaller = shown on top)
-        */
-       order?: number
-
-       /**
-        * Optional, localized, headline for the element
-        */
-       headline?: ILocalizedValue<string>
-
-       /**
-        * Optional link target for the element
-        */
-       link?: string
-
-       /**
-        * Optional date when this element will get valid (only show since then)
-        */
-       date?: number
-
-       /**
-        * Optional date when this element will be invalid (only show until then)
-        */
-       expiryDate?: number
-}
-
-/** Wrapper for media source and MIME type */
-type MediaSource = { src: string, mime: string }
-
-/**
- * Media content type for posts
- */
-interface IAppDiscoverMediaContent {
-       /**
-        * The media source to show - either one or a list of sources with their MIME type for fallback options
-        */
-       src: MediaSource | MediaSource[]
-
-       /**
-        * Alternative text for the media
-        */
-       alt: string
-
-       /**
-        * Optional link target for the media (e.g. to the full video)
-        */
-       link?: string
-}
-
-/**
- * Wrapper for post media
- */
-interface IAppDiscoverMedia {
-       /**
-        * The alignment of the media element
-        */
-       alignment?: 'start' | 'end' | 'center'
-
-       /**
-        * The (localized) content
-        */
-       content: ILocalizedValue<IAppDiscoverMediaContent>
-}
-
-/**
- * An app element only used for the showcase type
- */
-export interface IAppDiscoverApp {
-       /** The App ID */
-       type: 'app'
-       appId: string
-}
-
-export interface IAppDiscoverPost extends IAppDiscoverElement {
-       type: 'post'
-       text?: ILocalizedValue<string>
-       media?: IAppDiscoverMedia
-}
-
-export interface IAppDiscoverShowcase extends IAppDiscoverElement {
-       type: 'showcase'
-       content: (IAppDiscoverPost | IAppDiscoverApp)[]
-}
-
-export interface IAppDiscoverCarousel extends IAppDiscoverElement {
-       type: 'carousel'
-       text?: ILocalizedValue<string>
-       content: IAppDiscoverPost[]
-}
-
-export type IAppDiscoverElements = IAppDiscoverPost | IAppDiscoverCarousel | IAppDiscoverShowcase
diff --git a/apps/settings/src/constants/AppStoreCategoryIcons.ts b/apps/settings/src/constants/AppStoreCategoryIcons.ts
new file mode 100644 (file)
index 0000000..7e7e00d
--- /dev/null
@@ -0,0 +1,61 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import {
+       mdiAccount,
+       mdiAccountMultiple,
+       mdiArchive,
+       mdiCheck,
+       mdiClipboardFlow,
+       mdiClose,
+       mdiCog,
+       mdiControllerClassic,
+       mdiDownload,
+       mdiFileDocumentEdit,
+       mdiFolder,
+       mdiKey,
+       mdiMagnify,
+       mdiMonitorEye,
+       mdiMultimedia,
+       mdiOfficeBuilding,
+       mdiOpenInApp,
+       mdiSecurity,
+       mdiStar,
+       mdiStarCircleOutline,
+       mdiStarShooting,
+       mdiTools,
+       mdiViewColumn,
+} from '@mdi/js'
+
+/**
+ * SVG paths used for appstore category icons
+ */
+export default Object.freeze({
+       // system special categories
+       discover: mdiStarCircleOutline,
+       installed: mdiAccount,
+       enabled: mdiCheck,
+       disabled: mdiClose,
+       bundles: mdiArchive,
+       supported: mdiStarShooting,
+       featured: mdiStar,
+       updates: mdiDownload,
+
+       // generic categories
+       auth: mdiKey,
+       customization: mdiCog,
+       dashboard: mdiViewColumn,
+       files: mdiFolder,
+       games: mdiControllerClassic,
+       integration: mdiOpenInApp,
+       monitoring: mdiMonitorEye,
+       multimedia: mdiMultimedia,
+       office: mdiFileDocumentEdit,
+       organization: mdiOfficeBuilding,
+       search: mdiMagnify,
+       security: mdiSecurity,
+       social: mdiAccountMultiple,
+       tools: mdiTools,
+       workflow: mdiClipboardFlow,
+})
diff --git a/apps/settings/src/constants/AppStoreConstants.ts b/apps/settings/src/constants/AppStoreConstants.ts
new file mode 100644 (file)
index 0000000..d1dfacd
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { translate as t } from '@nextcloud/l10n'
+
+/**
+ * App store section names.
+ * This is needed for internal sections that have no already set display name by the backend.
+ */
+export const AppStoreSectionNames = Object.freeze({
+       discover: t('settings', 'Discover'),
+       installed: t('settings', 'Your apps'),
+       enabled: t('settings', 'Active apps'),
+       disabled: t('settings', 'Disabled apps'),
+       updates: t('settings', 'Updates'),
+       'app-bundles': t('settings', 'App bundles'),
+       featured: t('settings', 'Featured apps'),
+       supported: t('settings', 'Supported apps'), // From support subscription
+})
+
+/**
+ * App store categories that use the list view instead of the grid view
+ */
+export const AppStoreListViewCategories = Object.freeze([
+       'disabled',
+       'enabled',
+       'featured',
+       'installed',
+       'supported',
+       'updates',
+       'app-bundles',
+])
+
+/**
+ * Internal category used for the "results from other categories" fake-bundle
+ */
+export const AppStoreSearchResultsCategory = 'search-results'
+
+/**
+ * This app types can not be limited to groups as they are forced to be enabled for all users
+ */
+export const AlwaysEnabledAppTypes = [
+       'filesystem',
+       'prelogin',
+       'authentication',
+       'logging',
+       'prevent_group_restriction',
+]
diff --git a/apps/settings/src/constants/AppStoreDiscoverTypes.ts b/apps/settings/src/constants/AppStoreDiscoverTypes.ts
new file mode 100644 (file)
index 0000000..bc2736e
--- /dev/null
@@ -0,0 +1,117 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Currently known types of app discover section elements
+ */
+export const APP_DISCOVER_KNOWN_TYPES = ['post', 'showcase', 'carousel'] as const
+
+/**
+ * Helper for localized values
+ */
+export type ILocalizedValue<T> = Record<string, T | undefined> & { en: T }
+
+export interface IAppDiscoverElement {
+       /**
+        * Type of the element
+        */
+       type: typeof APP_DISCOVER_KNOWN_TYPES[number]
+
+       /**
+        * Identifier for this element
+        */
+       id: string,
+
+       /**
+        * Order of this element to pin elements (smaller = shown on top)
+        */
+       order?: number
+
+       /**
+        * Optional, localized, headline for the element
+        */
+       headline?: ILocalizedValue<string>
+
+       /**
+        * Optional link target for the element
+        */
+       link?: string
+
+       /**
+        * Optional date when this element will get valid (only show since then)
+        */
+       date?: number
+
+       /**
+        * Optional date when this element will be invalid (only show until then)
+        */
+       expiryDate?: number
+}
+
+/** Wrapper for media source and MIME type */
+type MediaSource = { src: string, mime: string }
+
+/**
+ * Media content type for posts
+ */
+interface IAppDiscoverMediaContent {
+       /**
+        * The media source to show - either one or a list of sources with their MIME type for fallback options
+        */
+       src: MediaSource | MediaSource[]
+
+       /**
+        * Alternative text for the media
+        */
+       alt: string
+
+       /**
+        * Optional link target for the media (e.g. to the full video)
+        */
+       link?: string
+}
+
+/**
+ * Wrapper for post media
+ */
+interface IAppDiscoverMedia {
+       /**
+        * The alignment of the media element
+        */
+       alignment?: 'start' | 'end' | 'center'
+
+       /**
+        * The (localized) content
+        */
+       content: ILocalizedValue<IAppDiscoverMediaContent>
+}
+
+/**
+ * An app element only used for the showcase type
+ */
+export interface IAppDiscoverApp {
+       /** The App ID */
+       type: 'app'
+       appId: string
+}
+
+export interface IAppDiscoverPost extends IAppDiscoverElement {
+       type: 'post'
+       text?: ILocalizedValue<string>
+       media?: IAppDiscoverMedia
+}
+
+export interface IAppDiscoverShowcase extends IAppDiscoverElement {
+       type: 'showcase'
+       content: (IAppDiscoverPost | IAppDiscoverApp)[]
+}
+
+export interface IAppDiscoverCarousel extends IAppDiscoverElement {
+       type: 'carousel'
+       text?: ILocalizedValue<string>
+       content: IAppDiscoverPost[]
+}
+
+export type IAppDiscoverElements = IAppDiscoverPost | IAppDiscoverCarousel | IAppDiscoverShowcase
diff --git a/apps/settings/src/constants/AppStoreTypes.ts b/apps/settings/src/constants/AppStoreTypes.ts
new file mode 100644 (file)
index 0000000..efba267
--- /dev/null
@@ -0,0 +1,119 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export interface IAppStoreCategory {
+       /**
+        * The category ID
+        */
+       id: string
+       /**
+        * The display name (can be localized)
+        */
+       displayName: string
+       /**
+        * Inline SVG path
+        */
+       icon: string
+}
+
+export interface IAppStoreAppRelease {
+       version: string
+       lastModified: string
+       translations: {
+               [key: string]: {
+                       changelog: string
+               }
+       }
+}
+
+export interface IAppStoreBundle {
+       /** Id to identify the bundle (see IAppStoreApp.bundleIds) */
+       id: string
+       /** Localized name */
+       name: string
+       /** @private Internal flag */
+       isCategory?: boolean
+}
+
+export interface IAppStoreApp {
+       /** The app id */
+       id: string
+       /** Display name of the app */
+       name: string
+       /** Summary describing the app */
+       summary: string
+       /** Longer description that could contain Markdown */
+       description: string
+       /** License applied to this app */
+       licence: string
+       author: string[] | Record<string, string>
+       /**
+        * Support level of this app.
+        * 100 = community app
+        * 200 = featured by Nextcloud
+        * 300 = included in support subscription
+        */
+       level: number
+       /** Rating score of this app on the app store */
+       score: number
+       /** Recent rating / score of this app on the app store */
+       ratingRecent: number
+       /** Number of overall ratings of this app on the app store */
+       ratingNumOverall: number
+       /** Number of recent ratings of this app on the app store */
+       ratingNumRecent: number
+       /** Version string of this app */
+       version: string
+       /** Category or categories applied to this app */
+       category: string|string[]
+       /** Bundles this app is part of */
+       bundleIds?: string[]
+       /** Types assigned to the app, e.g. `filesystem` */
+       types: string[]
+       /** Groups this app is limited to */
+       groups: string[]
+       /** URL of a preview image */
+       preview?: string
+       /** The preview is an icon */
+       previewAsIcon: boolean
+       /** URL of a screenshot of the app */
+       screenshot?: string
+
+       // Properties applied from Nextcloud not from the app store
+
+       /** If this app is currently enabled */
+       active: boolean
+       /** If this is an app shipped with the Nextcloud release */
+       shipped: boolean
+       /** If this app is currently installed */
+       installed: boolean
+       /** App is compatible with current Nextcloud version */
+       isCompatible: boolean
+       /** If the app was force enabled (ignore compatible Nextcloud version) */
+       isForceEnabled: boolean
+       /** App needs to be downloaded from app store first */
+       needsDownload?: boolean
+       /** Available version to update to */
+       update?: string
+       /** Dependencies missing for installation */
+       missingDependencies?: string[]
+
+       /** URL of the app website */
+       website?: string
+       /** URL of the issue tracker */
+       bugs?: string
+       /** Optional documentation for this app */
+       documentation?: {
+               /** URL of user documentation */
+               user?: string
+               /** URL of admin documentation */
+               admin?: string
+               /** URL of developer documentation */
+               developer?: string
+       }
+
+       appstoreData?: Record<string, unknown>
+       releases?: IAppStoreAppRelease[]
+}
diff --git a/apps/settings/src/constants/AppsConstants.js b/apps/settings/src/constants/AppsConstants.js
deleted file mode 100644 (file)
index c90e35c..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-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'),
-       updates: t('settings', 'Updates'),
-       'app-bundles': t('settings', 'App bundles'),
-       featured: t('settings', 'Featured apps'),
-       supported: t('settings', 'Supported apps'), // From subscription
-})
diff --git a/apps/settings/src/constants/AppstoreCategoryIcons.ts b/apps/settings/src/constants/AppstoreCategoryIcons.ts
deleted file mode 100644 (file)
index 7e7e00d..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import {
-       mdiAccount,
-       mdiAccountMultiple,
-       mdiArchive,
-       mdiCheck,
-       mdiClipboardFlow,
-       mdiClose,
-       mdiCog,
-       mdiControllerClassic,
-       mdiDownload,
-       mdiFileDocumentEdit,
-       mdiFolder,
-       mdiKey,
-       mdiMagnify,
-       mdiMonitorEye,
-       mdiMultimedia,
-       mdiOfficeBuilding,
-       mdiOpenInApp,
-       mdiSecurity,
-       mdiStar,
-       mdiStarCircleOutline,
-       mdiStarShooting,
-       mdiTools,
-       mdiViewColumn,
-} from '@mdi/js'
-
-/**
- * SVG paths used for appstore category icons
- */
-export default Object.freeze({
-       // system special categories
-       discover: mdiStarCircleOutline,
-       installed: mdiAccount,
-       enabled: mdiCheck,
-       disabled: mdiClose,
-       bundles: mdiArchive,
-       supported: mdiStarShooting,
-       featured: mdiStar,
-       updates: mdiDownload,
-
-       // generic categories
-       auth: mdiKey,
-       customization: mdiCog,
-       dashboard: mdiViewColumn,
-       files: mdiFolder,
-       games: mdiControllerClassic,
-       integration: mdiOpenInApp,
-       monitoring: mdiMonitorEye,
-       multimedia: mdiMultimedia,
-       office: mdiFileDocumentEdit,
-       organization: mdiOfficeBuilding,
-       search: mdiMagnify,
-       security: mdiSecurity,
-       social: mdiAccountMultiple,
-       tools: mdiTools,
-       workflow: mdiClipboardFlow,
-})