diff options
Diffstat (limited to 'apps/settings/src/components/AppStoreDiscover')
7 files changed, 992 insertions, 0 deletions
diff --git a/apps/settings/src/components/AppStoreDiscover/AppLink.vue b/apps/settings/src/components/AppStoreDiscover/AppLink.vue new file mode 100644 index 00000000000..703adb9f041 --- /dev/null +++ b/apps/settings/src/components/AppStoreDiscover/AppLink.vue @@ -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/AppStoreDiscover/AppStoreDiscoverSection.vue b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue new file mode 100644 index 00000000000..bb91940c763 --- /dev/null +++ b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue @@ -0,0 +1,119 @@ +<!-- + - 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="mdiEyeOffOutline" :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 { mdiEyeOffOutline } 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/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +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 new file mode 100644 index 00000000000..7263dc71041 --- /dev/null +++ b/apps/settings/src/components/AppStoreDiscover/AppType.vue @@ -0,0 +1,100 @@ +<!-- + - 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 new file mode 100644 index 00000000000..69393176835 --- /dev/null +++ b/apps/settings/src/components/AppStoreDiscover/CarouselType.vue @@ -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/components/NcButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +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 new file mode 100644 index 00000000000..090e9dee577 --- /dev/null +++ b/apps/settings/src/components/AppStoreDiscover/PostType.vue @@ -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/components/NcIconSvgWrapper' +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 new file mode 100644 index 00000000000..ac057b9ab7d --- /dev/null +++ b/apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue @@ -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/AppStoreDiscover/common.ts b/apps/settings/src/components/AppStoreDiscover/common.ts new file mode 100644 index 00000000000..277d4910e49 --- /dev/null +++ b/apps/settings/src/components/AppStoreDiscover/common.ts @@ -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 |