diff options
Diffstat (limited to 'apps')
-rw-r--r-- | apps/settings/src/components/AppList/AppItem.vue | 16 | ||||
-rw-r--r-- | apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue | 22 | ||||
-rw-r--r-- | apps/settings/src/components/AppStoreDiscover/AppType.vue | 117 | ||||
-rw-r--r-- | apps/settings/src/components/AppStoreDiscover/PostType.vue | 43 | ||||
-rw-r--r-- | apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue | 139 | ||||
-rw-r--r-- | apps/settings/src/constants/AppDiscoverTypes.ts | 27 | ||||
-rw-r--r-- | apps/settings/src/utils/appDiscoverParser.spec.ts | 96 | ||||
-rw-r--r-- | apps/settings/src/utils/appDiscoverParser.ts (renamed from apps/settings/src/utils/appDiscoverTypeParser.ts) | 22 |
8 files changed, 455 insertions, 27 deletions
diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue index 06bf162acf9..51d0997c007 100644 --- a/apps/settings/src/components/AppList/AppItem.vue +++ b/apps/settings/src/components/AppList/AppItem.vue @@ -21,7 +21,7 @@ --> <template> - <component :is="listView ? `tr` : `li`" + <component :is="listView ? 'tr' : (inline ? 'article' : 'li')" class="app-item" :class="{ 'app-item--list-view': listView, @@ -82,7 +82,10 @@ <AppLevelBadge :level="app.level" /> <AppScore v-if="hasRating && !listView" :score="app.score" /> </component> - <component :is="dataItemTag" :headers="getDataItemHeaders(`app-table-col-actions`)" class="app-actions"> + <component :is="dataItemTag" + v-if="!inline" + :headers="getDataItemHeaders(`app-table-col-actions`)" + class="app-actions"> <div v-if="app.error" class="warning"> {{ app.error }} </div> @@ -145,7 +148,10 @@ export default { type: Object, required: true, }, - category: {}, + category: { + type: String, + required: true, + }, listView: { type: Boolean, default: true, @@ -158,6 +164,10 @@ export default { type: String, default: null, }, + inline: { + type: Boolean, + default: false, + }, }, data() { return { diff --git a/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue index 68610347420..4e20a55bcde 100644 --- a/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue +++ b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue @@ -38,10 +38,11 @@ import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import logger from '../../logger' -import { apiTypeParser } from '../../utils/appDiscoverTypeParser.ts' +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[]>([]) @@ -50,7 +51,7 @@ const elements = ref<IAppDiscoverElements[]>([]) * Shuffle using the Fisher-Yates algorithm * @param array The array to shuffle (in place) */ -const shuffleArray = (array) => { +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]] @@ -64,8 +65,19 @@ const shuffleArray = (array) => { onBeforeMount(async () => { try { const { data } = await axios.get<Record<string, unknown>[]>(generateUrl('/settings/api/apps/discover')) - const parsedData = data.map(apiTypeParser) - elements.value = shuffleArray(parsedData) + 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) @@ -78,6 +90,8 @@ const getComponent = (type) => { return PostType } else if (type === 'carousel') { return CarouselType + } else if (type === 'showcase') { + return ShowcaseType } return defineComponent({ mounted: () => logger.error('Unknown component requested ', type), diff --git a/apps/settings/src/components/AppStoreDiscover/AppType.vue b/apps/settings/src/components/AppStoreDiscover/AppType.vue new file mode 100644 index 00000000000..badb560e684 --- /dev/null +++ b/apps/settings/src/components/AppStoreDiscover/AppType.vue @@ -0,0 +1,117 @@ +<!-- + - @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> + - + - @author Ferdinand Thiessen <opensource@fthiessen.de> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<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/PostType.vue b/apps/settings/src/components/AppStoreDiscover/PostType.vue index df4755483f8..c03cac4feaf 100644 --- a/apps/settings/src/components/AppStoreDiscover/PostType.vue +++ b/apps/settings/src/components/AppStoreDiscover/PostType.vue @@ -21,8 +21,12 @@ --> <template> <article :id="domId" + ref="container" class="app-discover-post" - :class="{ 'app-discover-post--reverse': media && media.alignment === 'start' }"> + :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" @@ -73,7 +77,7 @@ import type { PropType } from 'vue' import { mdiPlayCircleOutline } from '@mdi/js' import { generateUrl } from '@nextcloud/router' -import { useElementVisibility } from '@vueuse/core' +import { useElementSize, useElementVisibility } from '@vueuse/core' import { computed, defineComponent, ref, watchEffect } from 'vue' import { commonAppDiscoverProps } from './common' import { useLocalizedValue } from '../../composables/useGetLocalizedValue' @@ -139,6 +143,14 @@ export default defineComponent({ 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 */ @@ -171,6 +183,8 @@ export default defineComponent({ return { mdiPlayCircleOutline, + container, + translatedText, translatedHeadline, mediaElement, @@ -182,6 +196,7 @@ export default defineComponent({ showPlayVideo, isFullWidth, + isSmallWidth, isImage, generatePrivacyUrl, @@ -192,12 +207,15 @@ export default defineComponent({ <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; } @@ -210,15 +228,20 @@ export default defineComponent({ &__text { display: block; - padding: var(--border-radius-rounded); 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-height: 300px; max-width: 450px; border-radius: var(--border-radius-rounded); @@ -258,14 +281,20 @@ export default defineComponent({ } } -// Ensure section works on mobile devices -@media only screen and (max-width: 699px) { - .app-discover-post { +.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%; diff --git a/apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue b/apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue new file mode 100644 index 00000000000..cb4d118dd83 --- /dev/null +++ b/apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue @@ -0,0 +1,139 @@ +<!-- + - @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> + - + - @author Ferdinand Thiessen <opensource@fthiessen.de> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<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/constants/AppDiscoverTypes.ts b/apps/settings/src/constants/AppDiscoverTypes.ts index d28516fe79c..fe350eb9a35 100644 --- a/apps/settings/src/constants/AppDiscoverTypes.ts +++ b/apps/settings/src/constants/AppDiscoverTypes.ts @@ -42,6 +42,11 @@ export interface IAppDiscoverElement { id: string, /** + * Order of this element to pin elements (smaller = shown on top) + */ + order?: number + + /** * Optional, localized, headline for the element */ headline?: ILocalizedValue<string> @@ -54,12 +59,12 @@ export interface IAppDiscoverElement { /** * Optional date when this element will get valid (only show since then) */ - date?: Date|number + date?: number /** * Optional date when this element will be invalid (only show until then) */ - expiryDate?: Date|number + expiryDate?: number } /** Wrapper for media source and MIME type */ @@ -86,15 +91,6 @@ interface IAppDiscoverMediaContent { } /** - * An app element only used for the showcase type - */ -interface IAppDiscoverApp { - /** The App ID */ - type: 'app' - app: string -} - -/** * Wrapper for post media */ interface IAppDiscoverMedia { @@ -109,6 +105,15 @@ interface IAppDiscoverMedia { 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> diff --git a/apps/settings/src/utils/appDiscoverParser.spec.ts b/apps/settings/src/utils/appDiscoverParser.spec.ts new file mode 100644 index 00000000000..e00b24dff49 --- /dev/null +++ b/apps/settings/src/utils/appDiscoverParser.spec.ts @@ -0,0 +1,96 @@ +/** + * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> + * + * @author Ferdinand Thiessen <opensource@fthiessen.de> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import type { IAppDiscoverElement } from '../constants/AppDiscoverTypes' + +import { describe, expect, it } from '@jest/globals' +import { filterElements, parseApiResponse } from './appDiscoverParser' + +describe('App Discover API parser', () => { + describe('filterElements', () => { + it('can filter expired elements', () => { + const result = filterElements({ id: 'test', type: 'post', expiryDate: 100 }) + expect(result).toBe(false) + }) + + it('can filter upcoming elements', () => { + const result = filterElements({ id: 'test', type: 'post', date: Date.now() + 10000 }) + expect(result).toBe(false) + }) + + it('ignores element without dates', () => { + const result = filterElements({ id: 'test', type: 'post' }) + expect(result).toBe(true) + }) + + it('allows not yet expired elements', () => { + const result = filterElements({ id: 'test', type: 'post', expiryDate: Date.now() + 10000 }) + expect(result).toBe(true) + }) + + it('allows yet included elements', () => { + const result = filterElements({ id: 'test', type: 'post', date: 100 }) + expect(result).toBe(true) + }) + + it('allows elements included and not expired', () => { + const result = filterElements({ id: 'test', type: 'post', date: 100, expiryDate: Date.now() + 10000 }) + expect(result).toBe(true) + }) + + it('can handle null values', () => { + const result = filterElements({ id: 'test', type: 'post', date: null, expiryDate: null } as unknown as IAppDiscoverElement) + expect(result).toBe(true) + }) + }) + + describe('parseApiResponse', () => { + it('can handle basic post', () => { + const result = parseApiResponse({ id: 'test', type: 'post' }) + expect(result).toEqual({ id: 'test', type: 'post' }) + }) + + it('can handle carousel', () => { + const result = parseApiResponse({ id: 'test', type: 'carousel' }) + expect(result).toEqual({ id: 'test', type: 'carousel' }) + }) + + it('can handle showcase', () => { + const result = parseApiResponse({ id: 'test', type: 'showcase' }) + expect(result).toEqual({ id: 'test', type: 'showcase' }) + }) + + it('throws on unknown type', () => { + expect(() => parseApiResponse({ id: 'test', type: 'foo-bar' })).toThrow() + }) + + it('parses the date', () => { + const result = parseApiResponse({ id: 'test', type: 'showcase', date: '2024-03-19T17:28:19+0000' }) + expect(result).toEqual({ id: 'test', type: 'showcase', date: 1710869299000 }) + }) + + it('parses the expiryDate', () => { + const result = parseApiResponse({ id: 'test', type: 'showcase', expiryDate: '2024-03-19T17:28:19Z' }) + expect(result).toEqual({ id: 'test', type: 'showcase', expiryDate: 1710869299000 }) + }) + }) +}) diff --git a/apps/settings/src/utils/appDiscoverTypeParser.ts b/apps/settings/src/utils/appDiscoverParser.ts index ed20138e91b..96f7d3e4b7d 100644 --- a/apps/settings/src/utils/appDiscoverTypeParser.ts +++ b/apps/settings/src/utils/appDiscoverParser.ts @@ -20,14 +20,14 @@ * */ -import type { IAppDiscoverCarousel, IAppDiscoverElements, IAppDiscoverPost, IAppDiscoverShowcase } from '../constants/AppDiscoverTypes.ts' +import type { IAppDiscoverCarousel, IAppDiscoverElement, IAppDiscoverElements, IAppDiscoverPost, IAppDiscoverShowcase } from '../constants/AppDiscoverTypes.ts' /** * Helper to transform the JSON API results to proper frontend objects (app discover section elements) * * @param element The JSON API element to transform */ -export const apiTypeParser = (element: Record<string, unknown>): IAppDiscoverElements => { +export const parseApiResponse = (element: Record<string, unknown>): IAppDiscoverElements => { const appElement = { ...element } if (appElement.date) { appElement.date = Date.parse(appElement.date as string) @@ -45,3 +45,21 @@ export const apiTypeParser = (element: Record<string, unknown>): IAppDiscoverEle } throw new Error(`Invalid argument, app discover element with type ${element.type ?? 'unknown'} is unknown`) } + +/** + * Filter outdated or upcoming elements + * @param element Element to check + */ +export const filterElements = (element: IAppDiscoverElement) => { + const now = Date.now() + // Element not yet published + if (element.date && element.date > now) { + return false + } + + // Element expired + if (element.expiryDate && element.expiryDate < now) { + return false + } + return true +} |