aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/settings/src/components/AppList/AppItem.vue16
-rw-r--r--apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue22
-rw-r--r--apps/settings/src/components/AppStoreDiscover/AppType.vue117
-rw-r--r--apps/settings/src/components/AppStoreDiscover/PostType.vue43
-rw-r--r--apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue139
-rw-r--r--apps/settings/src/constants/AppDiscoverTypes.ts27
-rw-r--r--apps/settings/src/utils/appDiscoverParser.spec.ts96
-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
+}