Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>tags/v29.0.0beta3
@@ -38,6 +38,7 @@ 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' | |||
const PostType = defineAsyncComponent(() => import('./PostType.vue')) | |||
@@ -61,8 +62,9 @@ const shuffleArray = (array) => { | |||
*/ | |||
onBeforeMount(async () => { | |||
try { | |||
const { data } = await axios.get<IAppDiscoverElements[]>(generateUrl('/settings/api/apps/discover')) | |||
elements.value = shuffleArray(data) | |||
const { data } = await axios.get<Record<string, unknown>[]>(generateUrl('/settings/api/apps/discover')) | |||
const parsedData = data.map(apiTypeParser) | |||
elements.value = shuffleArray(parsedData) | |||
} catch (error) { | |||
hasError.value = true | |||
logger.error(error as Error) |
@@ -1,45 +1,169 @@ | |||
<!-- | |||
- @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> | |||
<article class="app-discover-post" | |||
:class="{ 'app-discover-post--reverse': media && media.alignment === 'start' }"> | |||
<div v-if="headline || text" class="app-discover-post__text"> | |||
<component :is="link ? 'a' : 'div'" | |||
v-if="headline || text" | |||
:href="link" | |||
:target="link ? '_blank' : undefined" | |||
class="app-discover-post__text"> | |||
<h3>{{ translatedHeadline }}</h3> | |||
<p>{{ translatedText }}</p> | |||
</div> | |||
<div v-if="media"> | |||
<img class="app-discover-post__media" :alt="mediaAlt" :src="mediaSource"> | |||
</div> | |||
</component> | |||
<component :is="mediaLink ? 'a' : 'div'" | |||
v-if="mediaSources" | |||
:href="mediaLink" | |||
:target="mediaLink ? '_blank' : undefined" | |||
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 : source.src" | |||
:srcset="isImage ? source.src : undefined" | |||
:type="source.mime"> | |||
<img v-if="isImage" | |||
:src="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 setup lang="ts"> | |||
import { getLanguage } from '@nextcloud/l10n' | |||
import { computed } from 'vue' | |||
<script lang="ts"> | |||
import type { IAppDiscoverPost } from '../../constants/AppDiscoverTypes.ts' | |||
import type { PropType } from 'vue' | |||
type ILocalizedValue<T> = Record<string, T | undefined> & { en: T } | |||
import { computed, defineComponent, ref, watchEffect } from 'vue' | |||
import { commonAppDiscoverProps } from './common' | |||
import { useLocalizedValue } from '../../composables/useGetLocalizedValue' | |||
import { useElementVisibility } from '@vueuse/core' | |||
import { mdiPlayCircleOutline } from '@mdi/js' | |||
const props = defineProps<{ | |||
type: string | |||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' | |||
headline: ILocalizedValue<string> | |||
text: ILocalizedValue<string> | |||
link?: string | |||
media: { | |||
alignment: 'start'|'end' | |||
content: ILocalizedValue<{ src: string, alt: string}> | |||
} | |||
}>() | |||
export default defineComponent({ | |||
components: { | |||
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, | |||
}, | |||
}, | |||
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 language = getLanguage() | |||
const isImage = computed(() => mediaSources?.value?.[0].mime.startsWith('image/') === true) | |||
/** | |||
* Is the media is shown full width | |||
*/ | |||
const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value) | |||
const getLocalizedValue = <T, >(dict: ILocalizedValue<T>) => dict[language] ?? dict[language.split('_')[0]] ?? dict.en | |||
/** | |||
* 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 translatedText = computed(() => getLocalizedValue(props.text)) | |||
const translatedHeadline = computed(() => getLocalizedValue(props.headline)) | |||
const hasPlaybackEnded = ref(false) | |||
const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value) | |||
const localizedMedia = computed(() => getLocalizedValue(props.media.content)) | |||
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 | |||
const mediaSource = computed(() => localizedMedia.value?.src) | |||
const mediaAlt = '' | |||
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, | |||
translatedText, | |||
translatedHeadline, | |||
mediaElement, | |||
mediaSources, | |||
mediaAlt, | |||
mediaLink, | |||
hasPlaybackEnded, | |||
showPlayVideo, | |||
isFullWidth, | |||
isImage, | |||
} | |||
}, | |||
}) | |||
</script> | |||
<style scoped lang="scss"> | |||
@@ -61,21 +185,79 @@ const mediaAlt = '' | |||
} | |||
&__text { | |||
display: block; | |||
padding: var(--border-radius-rounded); | |||
width: 100%; | |||
} | |||
&__media { | |||
display: block; | |||
overflow: hidden; | |||
max-height: 300px; | |||
max-width: 450px; | |||
border-radius: var(--border-radius-rounded); | |||
border-end-start-radius: 0; | |||
border-start-start-radius: 0; | |||
&--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; | |||
} | |||
} | |||
&--reverse &__media { | |||
border-radius: var(--border-radius-rounded); | |||
border-end-end-radius: 0; | |||
border-start-end-radius: 0; | |||
&__play-icon { | |||
&-wrapper { | |||
position: relative; | |||
top: -50%; | |||
left: -50%; | |||
} | |||
position: absolute; | |||
top: -46px; // half of the icon height | |||
right: -46px; // half of the icon width | |||
} | |||
} | |||
// Ensure section works on mobile devices | |||
@media only screen and (max-width: 699px) { | |||
.app-discover-post { | |||
flex-direction: column; | |||
&--reverse { | |||
flex-direction: column-reverse; | |||
} | |||
&__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> |
@@ -0,0 +1,65 @@ | |||
/** | |||
* @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 { 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 |
@@ -0,0 +1,47 @@ | |||
/** | |||
* @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 { ILocalizedValue } from '../constants/AppDiscoverTypes' | |||
import { getLanguage } from '@nextcloud/l10n' | |||
import { computed, type Ref } from 'vue' | |||
/** | |||
* Helper to get the localized value for the current users language | |||
* @param dict The dictionary to get the value from | |||
* @param language The language to use | |||
*/ | |||
const getLocalizedValue = <T, >(dict: ILocalizedValue<T>, language: string) => dict[language] ?? dict[language.split('_')[0]] ?? dict.en ?? null | |||
/** | |||
* Get the localized value of the dictionary provided | |||
* @param dict Dictionary | |||
* @return String or null if invalid dictionary | |||
*/ | |||
export const useLocalizedValue = <T, >(dict: Ref<ILocalizedValue<T|undefined>|undefined|null>) => { | |||
/** | |||
* Language of the current user | |||
*/ | |||
const language = getLanguage() | |||
return computed(() => !dict?.value ? null : getLocalizedValue<T>(dict.value as ILocalizedValue<T>, language)) | |||
} |
@@ -0,0 +1,129 @@ | |||
/** | |||
* @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/>. | |||
* | |||
*/ | |||
/** | |||
* 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, | |||
/** | |||
* 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?: Date|number | |||
/** | |||
* Optional date when this element will be invalid (only show until then) | |||
*/ | |||
expiryDate?: Date|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 | |||
} | |||
/** | |||
* An app element only used for the showcase type | |||
*/ | |||
interface IAppDiscoverApp { | |||
/** The App ID */ | |||
type: 'app' | |||
app: string | |||
} | |||
/** | |||
* Wrapper for post media | |||
*/ | |||
interface IAppDiscoverMedia { | |||
/** | |||
* The alignment of the media element | |||
*/ | |||
alignment?: 'start' | 'end' | 'center' | |||
/** | |||
* The (localized) content | |||
*/ | |||
content: ILocalizedValue<IAppDiscoverMediaContent> | |||
} | |||
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 | IAppDiscoverApp)[] | |||
} | |||
export type IAppDiscoverElements = IAppDiscoverPost | IAppDiscoverCarousel | IAppDiscoverShowcase |
@@ -0,0 +1,47 @@ | |||
/** | |||
* @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 { IAppDiscoverCarousel, 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 => { | |||
const appElement = { ...element } | |||
if (appElement.date) { | |||
appElement.date = Date.parse(appElement.date as string) | |||
} | |||
if (appElement.expiryDate) { | |||
appElement.expiryDate = Date.parse(appElement.expiryDate as string) | |||
} | |||
if (appElement.type === 'post') { | |||
return appElement as unknown as IAppDiscoverPost | |||
} else if (appElement.type === 'showcase') { | |||
return appElement as unknown as IAppDiscoverShowcase | |||
} else if (appElement.type === 'carousel') { | |||
return appElement as unknown as IAppDiscoverCarousel | |||
} | |||
throw new Error(`Invalid argument, app discover element with type ${element.type ?? 'unknown'} is unknown`) | |||
} |