浏览代码

feat(settings): Implement `post` type for app discover section

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
tags/v29.0.0beta3
Ferdinand Thiessen 3 个月前
父节点
当前提交
aa29204fe0
没有帐户链接到提交者的电子邮件

+ 4
- 2
apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue 查看文件

@@ -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)

+ 214
- 32
apps/settings/src/components/AppStoreDiscover/PostType.vue 查看文件

@@ -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>

+ 65
- 0
apps/settings/src/components/AppStoreDiscover/common.ts 查看文件

@@ -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

+ 47
- 0
apps/settings/src/composables/useGetLocalizedValue.ts 查看文件

@@ -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))
}

+ 129
- 0
apps/settings/src/constants/AppDiscoverTypes.ts 查看文件

@@ -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

+ 47
- 0
apps/settings/src/utils/appDiscoverTypeParser.ts 查看文件

@@ -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`)
}

正在加载...
取消
保存