+++ /dev/null
-/**
- * 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[]
-}
+++ /dev/null
-<!--
- - 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>
+++ /dev/null
-<!--
- - 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>
--- /dev/null
+<!--
+ - 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>
--- /dev/null
+<!--
+ - 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>
--- /dev/null
+<!--
+ - 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>
--- /dev/null
+<!--
+ - 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>
--- /dev/null
+<!--
+ - 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>
--- /dev/null
+<!--
+ - 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>
--- /dev/null
+<!--
+ - 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>
--- /dev/null
+/**
+ * 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
--- /dev/null
+<!--
+ - 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>
--- /dev/null
+<!--
+ - 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>
--- /dev/null
+<!--
+ - 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>
+++ /dev/null
-<!--
- - 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>
+++ /dev/null
-<!--
- - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-<template>
- <div class="app-discover">
- <NcEmptyContent v-if="hasError"
- :name="t('settings', 'Nothing to show')"
- :description="t('settings', 'Could not load section content from app store.')">
- <template #icon>
- <NcIconSvgWrapper :path="mdiEyeOff" :size="64" />
- </template>
- </NcEmptyContent>
- <NcEmptyContent v-else-if="elements.length === 0"
- :name="t('settings', 'Loading')"
- :description="t('settings', 'Fetching the latest news…')">
- <template #icon>
- <NcLoadingIcon :size="64" />
- </template>
- </NcEmptyContent>
- <template v-else>
- <component :is="getComponent(entry.type)"
- v-for="entry, index in elements"
- :key="entry.id ?? index"
- v-bind="entry" />
- </template>
- </div>
-</template>
-
-<script setup lang="ts">
-import type { IAppDiscoverElements } from '../../constants/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>
+++ /dev/null
-<!--
- - 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>
+++ /dev/null
-<!--
- - 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>
+++ /dev/null
-<!--
- - 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>
+++ /dev/null
-<!--
- - 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>
+++ /dev/null
-/**
- * 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
+++ /dev/null
-<!--
- - 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>
+++ /dev/null
-<!--
- - 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>
+++ /dev/null
-<!--
- - 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>
+++ /dev/null
-/**
- * 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
--- /dev/null
+/**
+ * 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,
+})
--- /dev/null
+/**
+ * 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',
+]
--- /dev/null
+/**
+ * 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
--- /dev/null
+/**
+ * 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[]
+}
+++ /dev/null
-/**
- * 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
-})
+++ /dev/null
-/**
- * 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,
-})