</template>
<script setup lang="ts">
-import type { IAppstoreApp } from '../../../app-types'
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes'
import { mdiTextShort } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import Markdown from '../../Markdown.vue'
defineProps<{
- app: IAppstoreApp,
+ app: IAppStoreApp,
}>()
</script>
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
-
<template>
<NcAppSidebarTab id="details"
:name="t('settings', 'Details')"
</template>
<div class="app-details">
<div class="app-details__actions">
- <div v-if="app.active && canLimitToGroups(app)" class="app-details__actions-groups">
- <input :id="`groups_enable_${app.id}`"
- v-model="groupCheckedAppsData"
- type="checkbox"
- :value="app.id"
- class="groups-enable__checkbox checkbox"
- @change="setGroupLimit">
- <label :for="`groups_enable_${app.id}`">{{ t('settings', 'Limit to groups') }}</label>
- <input type="hidden"
- class="group_select"
- :title="t('settings', 'All')"
- value="">
- <br>
- <label for="limitToGroups">
- <span>{{ t('settings', 'Limit app usage to groups') }}</span>
- </label>
- <NcSelect v-if="isLimitedToGroups(app)"
- input-id="limitToGroups"
- :options="groups"
- :value="appGroups"
- :limit="5"
- label="name"
- :multiple="true"
- :close-on-select="false"
- @option:selected="addGroupLimitation"
- @option:deselected="removeGroupLimitation"
- @search="asyncFindGroup">
- <span slot="noResult">{{ t('settings', 'No results') }}</span>
- </NcSelect>
- </div>
- <div class="app-details__actions-manage">
- <input v-if="app.update"
- class="update primary"
- type="button"
- :value="t('settings', 'Update to {version}', { version: app.update })"
- :disabled="installing || isLoading"
- @click="update(app.id)">
- <input v-if="app.canUnInstall"
- class="uninstall"
- type="button"
- :value="t('settings', 'Remove')"
- :disabled="installing || isLoading"
- @click="remove(app.id)">
- <input v-if="app.active"
- class="enable"
- type="button"
- :value="t('settings','Disable')"
- :disabled="installing || isLoading"
- @click="disable(app.id)">
- <input v-if="!app.active && (app.canInstall || app.isCompatible)"
- :title="enableButtonTooltip"
- :aria-label="enableButtonTooltip"
- class="enable primary"
- type="button"
- :value="enableButtonText"
- :disabled="!app.canInstall || installing || isLoading"
- @click="enable(app.id)">
- <input v-else-if="!app.active && !app.canInstall"
- :title="forceEnableButtonTooltip"
- :aria-label="forceEnableButtonTooltip"
- class="enable force"
- type="button"
- :value="forceEnableButtonText"
- :disabled="installing || isLoading"
- @click="forceEnable(app.id)">
- </div>
+ <AppItemActions :app="app"
+ class="app-details__actions-manage"
+ data-item-tag="div" />
+ <AppDetailsTabLimitGroups v-if="app.active" :app="app" />
</div>
- <ul class="app-details__dependencies">
- <li v-if="app.missingMinOwnCloudVersion">
- {{ t('settings', 'This app has no minimum Nextcloud version assigned. This will be an error in the future.') }}
- </li>
- <li v-if="app.missingMaxOwnCloudVersion">
- {{ t('settings', 'This app has no maximum Nextcloud version assigned. This will be an error in the future.') }}
- </li>
- <li v-if="!app.canInstall">
+ <NcNoteCard v-if="!canInstall">
+ <template #icon>
+ <span />
+ </template>
+ <p class="app-details__dependency-note">
{{ t('settings', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
- <ul class="missing-dependencies">
- <li v-for="(dep, index) in app.missingDependencies" :key="index">
- {{ dep }}
- </li>
- </ul>
- </li>
- </ul>
-
- <div v-if="lastModified && !app.shipped" class="app-details__section">
+ </p>
+ <ul :aria-label="t('settings', 'Missing dependencies')" class="missing-dependencies">
+ <li v-for="(dep, index) in app.missingDependencies" :key="index">
+ {{ dep }}
+ </li>
+ </ul>
+ </NcNoteCard>
+
+ <NcNoteCard v-if="app.shipped"
+ :text="t('settings', 'This app is bundled with Nextcloud.')"
+ type="info" />
+ <div v-else-if="lastModified" class="app-details__section">
<h4>
{{ t('settings', 'Latest updated') }}
</h4>
<h4>{{ t('settings', 'Interact') }}</h4>
<div class="app-details__interact">
<NcButton :disabled="!app.bugs"
- :href="app.bugs ?? '#'"
:aria-label="t('settings', 'Report a bug')"
+ :href="app.bugs ?? '#'"
+ target="_blank"
:title="t('settings', 'Report a bug')">
<template #icon>
<NcIconSvgWrapper :path="mdiBug" />
</template>
</NcButton>
<NcButton :disabled="!app.bugs"
- :href="app.bugs ?? '#'"
:aria-label="t('settings', 'Request feature')"
+ :href="app.bugs ?? '#'"
+ target="_blank"
:title="t('settings', 'Request feature')">
<template #icon>
<NcIconSvgWrapper :path="mdiFeatureSearch" />
</template>
</NcButton>
<NcButton v-if="app.appstoreData?.discussion"
- :href="app.appstoreData.discussion"
:aria-label="t('settings', 'Ask questions or discuss')"
+ :href="app.appstoreData.discussion"
+ target="_blank"
:title="t('settings', 'Ask questions or discuss')">
<template #icon>
<NcIconSvgWrapper :path="mdiTooltipQuestion" />
</template>
</NcButton>
- <NcButton v-if="!app.internal"
- :href="rateAppUrl"
+ <NcButton v-if="!app.shipped"
:aria-label="t('settings', 'Rate the app')"
+ :href="rateAppUrl"
+ target="_blank"
:title="t('settings', 'Rate')">
<template #icon>
<NcIconSvgWrapper :path="mdiStar" />
</NcAppSidebarTab>
</template>
-<script>
+<script setup lang="ts">
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes'
+
+import { mdiBug, mdiFeatureSearch, mdiStar, mdiTextBox, mdiTooltipQuestion } from '@mdi/js'
+import { t } from '@nextcloud/l10n'
+import { computed, ref, toRef, watch } from 'vue'
+import { useAppStore } from '../../../store/appStore'
+import { useAppState } from '../../../composables/useAppState'
+
+import AppDetailsTabLimitGroups from './AppDetailsTabLimitGroups.vue'
+import AppItemActions from '../AppItem/AppItemActions.vue'
import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-
-import AppManagement from '../../../mixins/AppManagement.js'
-import { mdiBug, mdiFeatureSearch, mdiStar, mdiTextBox, mdiTooltipQuestion } from '@mdi/js'
-import { useAppsStore } from '../../../store/apps-store'
-
-export default {
- name: 'AppDetailsTab',
-
- components: {
- NcAppSidebarTab,
- NcButton,
- NcDateTime,
- NcIconSvgWrapper,
- NcSelect,
- },
- mixins: [AppManagement],
-
- props: {
- app: {
- type: Object,
- required: true,
- },
- },
-
- setup() {
- const store = useAppsStore()
-
- return {
- store,
-
- mdiBug,
- mdiFeatureSearch,
- mdiStar,
- mdiTextBox,
- mdiTooltipQuestion,
+import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+
+const props = defineProps<{ app: IAppStoreApp }>()
+const store = useAppStore()
+
+const { canInstall } = useAppState(toRef(props, 'app'))
+
+/**
+ * Is this app limited to specific groups
+ */
+const limitAppToGroups = ref(false)
+watch(() => props.app.groups, () => {
+ limitAppToGroups.value = props.app.groups.length > 0
+}, { immediate: true })
+
+/**
+ * Date of last release of this app
+ */
+const lastModified = computed(() => (props.app?.releases ?? [])
+ .map(({ lastModified }) => Date.parse(lastModified))
+ .sort()
+ .at(0) ?? null,
+)
+
+/**
+ * App authors as comma separated string
+ */
+const appAuthors = computed(() => {
+ const authors = Array.isArray(props.app.author)
+ ? props.app.author.map(authorName)
+ : [authorName(props.app.author)]
+
+ return authors
+ .sort((a, b) => a.split(' ').at(-1).localeCompare(b.split(' ').at(-1)))
+ .join(', ')
+})
+
+/**
+ * URL of this app in the app store
+ */
+const appstoreUrl = computed(() => `https://apps.nextcloud.com/apps/${props.app.id}`)
+
+/**
+ * URL to rate this app on the app store
+ */
+const rateAppUrl = computed(() => `${appstoreUrl.value}#comments`)
+
+/**
+ * The categories this app is part of (formatted display names)
+ */
+const appCategories = computed(() => [props.app.category].flat()
+ .map((id) => store.getCategoryById(id)?.displayName ?? id)
+ .join(', '),
+)
+
+/**
+ * Further external resources (e.g. website)
+ */
+const externalResources = computed(() => {
+ const resources: { id: string, href: string, label: string }[] = []
+
+ if (!props.app.shipped) {
+ resources.push({
+ id: 'appstore',
+ href: appstoreUrl.value,
+ label: t('settings', 'View in store'),
+ })
+ }
+ if (props.app.website) {
+ resources.push({
+ id: 'website',
+ href: props.app.website,
+ label: t('settings', 'Visit website'),
+ })
+ }
+ if (props.app.documentation) {
+ if (props.app.documentation.user) {
+ resources.push({
+ id: 'doc-user',
+ href: props.app.documentation.user,
+ label: t('settings', 'Usage documentation'),
+ })
}
- },
-
- data() {
- return {
- groupCheckedAppsData: false,
+ if (props.app.documentation.admin) {
+ resources.push({
+ id: 'doc-admin',
+ href: props.app.documentation.admin,
+ label: t('settings', 'Admin documentation'),
+ })
}
- },
-
- computed: {
- lastModified() {
- return (this.app.appstoreData?.releases ?? [])
- .map(({ lastModified }) => Date.parse(lastModified))
- .sort()
- .at(0) ?? null
- },
- /**
- * App authors as comma separated string
- */
- appAuthors() {
- console.warn(this.app)
- if (!this.app) {
- return ''
- }
-
- const authorName = (xmlNode) => {
- if (xmlNode['@value']) {
- // Complex node (with email or homepage attribute)
- return xmlNode['@value']
- }
- // Simple text node
- return xmlNode
- }
-
- const authors = Array.isArray(this.app.author)
- ? this.app.author.map(authorName)
- : [authorName(this.app.author)]
-
- return authors
- .sort((a, b) => a.split(' ').at(-1).localeCompare(b.split(' ').at(-1)))
- .join(', ')
- },
-
- appstoreUrl() {
- return `https://apps.nextcloud.com/apps/${this.app.id}`
- },
-
- /**
- * Further external resources (e.g. website)
- */
- externalResources() {
- const resources = []
- if (!this.app.internal) {
- resources.push({
- id: 'appstore',
- href: this.appstoreUrl,
- label: t('settings', 'View in store'),
- })
- }
- if (this.app.website) {
- resources.push({
- id: 'website',
- href: this.app.website,
- label: t('settings', 'Visit website'),
- })
- }
- if (this.app.documentation) {
- if (this.app.documentation.user) {
- resources.push({
- id: 'doc-user',
- href: this.app.documentation.user,
- label: t('settings', 'Usage documentation'),
- })
- }
- if (this.app.documentation.admin) {
- resources.push({
- id: 'doc-admin',
- href: this.app.documentation.admin,
- label: t('settings', 'Admin documentation'),
- })
- }
- if (this.app.documentation.developer) {
- resources.push({
- id: 'doc-developer',
- href: this.app.documentation.developer,
- label: t('settings', 'Developer documentation'),
- })
- }
- }
- return resources
- },
-
- appCategories() {
- return [this.app.category].flat()
- .map((id) => this.store.getCategoryById(id)?.displayName ?? id)
- .join(', ')
- },
-
- rateAppUrl() {
- return `${this.appstoreUrl}#comments`
- },
- appGroups() {
- return this.app.groups.map(group => { return { id: group, name: group } })
- },
- groups() {
- return this.$store.getters.getGroups
- .filter(group => group.id !== 'disabled')
- .sort((a, b) => a.name.localeCompare(b.name))
- },
- },
- mounted() {
- if (this.app.groups.length > 0) {
- this.groupCheckedAppsData = true
+ if (props.app.documentation.developer) {
+ resources.push({
+ id: 'doc-developer',
+ href: props.app.documentation.developer,
+ label: t('settings', 'Developer documentation'),
+ })
}
- },
+ }
+ return resources
+})
+
+/**
+ * Format an author name
+ * @param xmlNode the XML node from the app store
+ */
+function authorName(xmlNode) {
+ if (xmlNode['@value']) {
+ // Complex node (with email or homepage attribute)
+ return xmlNode['@value']
+ }
+ // Simple text node
+ return xmlNode
}
</script>
<style scoped lang="scss">
.app-details {
- padding: 20px;
+ padding: calc(2 * var(--default-grid-baseline));
&__actions {
// app management
&-manage {
+ margin-block-end: calc(2 * var(--default-grid-baseline));
+
+ :deep(.app-item-actions__wrapper) {
+ justify-content: start;
+ }
+
// if too many, shrink them and ellipsis
- display: flex;
- input {
- flex: 0 1 auto;
+ :deep(button) {
+ flex: 1;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
- &__authors {
- color: var(--color-text-maxcontrast);
+
+ &__dependency-note {
+ margin-block-end: var(--default-grid-baseline);
}
&__section {
- margin-top: 15px;
+ margin-top: calc(3 * var(--default-grid-baseline));
+ color: var(--color-text-maxcontrast);
h4 {
+ color: var(--color-main-text);
font-size: 16px;
font-weight: bold;
- margin-block-end: 5px;
+ margin-block-end: var(--default-grid-baseline);
}
}
--- /dev/null
+<script setup lang="ts">
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes'
+
+import axios from '@nextcloud/axios'
+import { t } from '@nextcloud/l10n'
+import { generateOcsUrl } from '@nextcloud/router'
+import { useElementVisibility, useThrottleFn } from '@vueuse/core'
+import { computed, ref, watchEffect } from 'vue'
+import { AlwaysEnabledAppTypes } from '../../../constants/AppStoreConstants'
+import { useAppStore } from '../../../store/appStore'
+import { useAppManagement } from '../../../composables/useAppManagement'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+
+const props = defineProps<{app: IAppStoreApp}>()
+const store = useAppStore()
+const { enableApp } = useAppManagement()
+
+/** Check that the current app can be limited to groups. For some app types this is not possible. */
+const canLimitToGroups = computed(() => props.app.types.every((type) => !AlwaysEnabledAppTypes.includes(type)))
+/** Frontend only property to track if the group-select should be shown */
+const limitAppToGroups = ref(false)
+
+// This is used to track the scroll state of the select
+// to query a new bunch of groups
+const loadMoreElement = ref<HTMLLIElement>()
+const loadMore = useElementVisibility(loadMoreElement)
+watchEffect(() => {
+ if (loadMoreElement.value && loadMore.value) {
+ fetchGroups()
+ }
+})
+
+/** Search query on the group select */
+const searchQuery = ref('')
+/** Limit of groups to fetch in one query */
+const limit = 10
+/** All loaded groups */
+const groups = ref<string[]>([])
+/** Current offset of querying groups (reset on search-query changes) */
+const offset = ref(0)
+/** If more groups can be fetched for the current search query */
+const hasMore = ref(true)
+
+/**
+ * Load a new chunk of groups from the API
+ * @todo This should be moved to an API package
+ */
+async function fetchGroups() {
+ if (hasMore.value === false) {
+ return
+ }
+
+ const url = generateOcsUrl(
+ `cloud/groups?offset={offset}&limit=${limit}${searchQuery.value ? '&search={search}' : ''}`,
+ {
+ offset,
+ search: searchQuery.value,
+ },
+ )
+ const { data } = await axios.get<OCSResponse<{ groups: string[] }>>(url)
+ const loadedGroups = data.ocs.data.groups
+ offset.value += loadedGroups.length
+ hasMore.value = loadedGroups.length >= limit
+ // Add all *new* groups to the list
+ groups.value.push(...loadedGroups.filter((group) => !groups.value.includes(group)))
+}
+
+/**
+ * Callback function for NcSelect.search
+ * @param search Query string for the group
+ * @param loading Loading callback
+ */
+async function handleSearch(search, loading: (l: boolean) => void) {
+ loading(true)
+ searchQuery.value = search
+ offset.value = 0
+ hasMore.value = true
+ await fetchGroups()
+ loading(false)
+}
+/** Debounced (throttled) search callback when user enters a group name only one request every 500ms is sent */
+const onSearch = useThrottleFn(handleSearch, 500)
+
+/**
+ * Save the updated groups on the store
+ * @param groups The new groups this app is limited to
+ */
+async function updateGroups(groups: string[]) {
+ store.updateApp(props.app, { groups })
+ await enableApp(props.app)
+}
+</script>
+
+<template>
+ <div v-if="canLimitToGroups" class="app-details-tab-limit-groups">
+ <NcCheckboxRadioSwitch :checked.sync="limitAppToGroups" class="app-details-tab-limit-groups__checkbox">
+ {{ t('settings', 'Limit to groups') }}
+ </NcCheckboxRadioSwitch>
+
+ <NcSelect v-if="limitAppToGroups"
+ :options="groups"
+ :value="app.groups"
+ label="name"
+ :input-label="t('settings', 'Limit app usage to groups')"
+ multiple
+ @search="onSearch"
+ @input="updateGroups">
+ <template #list-footer>
+ <li v-if="hasMore" ref="loadMoreElement" class="load-more">
+ {{ t('settings', 'Loading more …') }}
+ </li>
+ </template>
+ <template #no-result>
+ {{ t('settings', 'No results') }}
+ </template>
+ </NcSelect>
+ </div>
+</template>
+
+<style scoped>
+.app-details-tab-limit-groups__checkbox {
+ margin-block-end: calc(2 * var(--default-grid-baseline));
+}
+</style>
+++ /dev/null
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import type { Ref } from 'vue'
-import type { IAppstoreApp } from '../app-types.ts'
-
-import { mdiCog } from '@mdi/js'
-import { computed, ref, watchEffect } from 'vue'
-import AppstoreCategoryIcons from '../constants/AppstoreCategoryIcons.ts'
-import logger from '../logger.ts'
-
-/**
- * Get the app icon raw SVG for use with `NcIconSvgWrapper` (do never use without sanitizing)
- * It has a fallback to the categroy icon.
- *
- * @param app The app to get the icon for
- */
-export function useAppIcon(app: Ref<IAppstoreApp>) {
- const appIcon = ref<string|null>(null)
-
- /**
- * Fallback value if no app icon available
- */
- const categoryIcon = computed(() => {
- const path = [app.value?.category ?? []].flat()
- .map((name) => AppstoreCategoryIcons[name])
- .filter((icon) => !!icon)
- .at(0)
- ?? mdiCog
- return path ? `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="${path}" /></svg>` : null
- })
-
- watchEffect(async () => {
- // Note: Only variables until the first `await` will be watched!
- if (!app.value?.preview) {
- appIcon.value = categoryIcon.value
- } else {
- appIcon.value = null
- // Now try to load the real app icon
- try {
- const response = await window.fetch(app.value.preview)
- const blob = await response.blob()
- const rawSvg = await blob.text()
- appIcon.value = rawSvg.replaceAll(/fill="#(fff|ffffff)([a-z0-9]{1,2})?"/ig, 'fill="currentColor"')
- } catch (error) {
- appIcon.value = categoryIcon.value
- logger.error('Could not load app icon', { error })
- }
- }
- })
-
- return {
- appIcon,
- }
-}
--- /dev/null
+<!--
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <!-- Selected app details -->
+ <NcAppSidebar v-if="showSidebar"
+ class="app-sidebar"
+ :class="{ 'app-sidebar--with-screenshot': hasScreenshot }"
+ :active.sync="activeTab"
+ :background="hasScreenshot ? app.screenshot : undefined"
+ :compact="!hasScreenshot"
+ :name="app.name"
+ :title="app.name"
+ :subname="licenseText"
+ :subtitle="licenseText"
+ @close="hideAppDetails">
+ <!-- Fallback icon incase no app icon is available -->
+ <template v-if="!hasScreenshot" #header>
+ <AppItemIcon :app="app" class="app-sidebar__fallback-icon" />
+ </template>
+
+ <template #description>
+ <!-- Featured/Supported badges -->
+ <div class="app-sidebar__badges">
+ <AppLevelBadge :level="app.level" />
+ <AppScore v-if="appRating" :score="appRating" />
+ </div>
+ </template>
+
+ <!-- Tab content -->
+ <AppDescriptionTab :app="app" />
+ <AppDetailsTab :app="app" />
+ <AppReleasesTab :app="app" />
+ </NcAppSidebar>
+</template>
+
+<script setup lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { computed, ref, watch, watchEffect } from 'vue'
+import { useRoute, useRouter } from 'vue-router/composables'
+import { useAppRating } from '../../composables/useAppRating.ts'
+import { useAppStore } from '../../store/appStore.ts'
+import { preloadImage } from '../../service/imagePreloading.ts'
+
+import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar.js'
+import AppScore from '../../components/AppStore/AppScore.vue'
+import AppLevelBadge from '../../components/AppStore/AppLevelBadge.vue'
+import AppDescriptionTab from '../../components/AppStore/AppStoreSidebar/AppDescriptionTab.vue'
+import AppDetailsTab from '../../components/AppStore/AppStoreSidebar/AppDetailsTab.vue'
+import AppReleasesTab from '../../components/AppStore/AppStoreSidebar/AppReleasesTab.vue'
+import AppItemIcon from '../../components/AppStore/AppItem/AppItemIcon.vue'
+import logger from '../../logger.ts'
+
+const route = useRoute()
+const router = useRouter()
+const store = useAppStore()
+
+const appId = computed(() => route.params.appId ?? '')
+const app = computed(() => store.getAppById(appId.value)!)
+const appRating = useAppRating(app)
+
+const showSidebar = computed(() => app.value !== null)
+
+/**
+ * The second text line shown on the sidebar
+ */
+const licenseText = computed(() => app.value ? t('settings', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.licence.toString().toUpperCase() }) : '')
+
+const activeTab = ref('details')
+watch([app], () => { activeTab.value = 'details' })
+
+/**
+ * Hide the details sidebar by pushing a new route
+ */
+const hideAppDetails = () => {
+ router.push(route.name === 'discover'
+ ? { name: 'discover' }
+ : { name: 'app-category', params: { category: route.params.category } },
+ )
+}
+
+/**
+ * Whether the app screenshot is loaded
+ */
+const screenshotLoaded = ref(false)
+const hasScreenshot = computed(() => app.value?.screenshot && screenshotLoaded.value)
+watchEffect(() => {
+ if (app.value?.screenshot) {
+ screenshotLoaded.value = false
+ preloadImage(app.value.screenshot)
+ .then(() => { screenshotLoaded.value = true })
+ .catch((error) => logger.warn('Could not load preview for app', { app: app.value, error }))
+ }
+})
+</script>
+
+<style scoped lang="scss">
+.app-sidebar {
+ // If a screenshot is available it should cover the whole figure
+ &--with-screenshot {
+ :deep(.app-sidebar-header__figure) {
+ background-size: cover;
+ }
+ }
+
+ &__fallback-icon {
+ --app-icon-size: 100% !important;
+ }
+
+ &__badges {
+ display: flex;
+ flex-direction: row;
+ gap: 12px;
+ }
+
+ &__version {
+ color: var(--color-text-maxcontrast);
+ }
+}
+</style>
+++ /dev/null
-<!--
- - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-<template>
- <!-- Selected app details -->
- <NcAppSidebar v-if="showSidebar"
- class="app-sidebar"
- :class="{ 'app-sidebar--with-screenshot': hasScreenshot }"
- :active.sync="activeTab"
- :background="hasScreenshot ? app.screenshot : undefined"
- :compact="!hasScreenshot"
- :name="app.name"
- :title="app.name"
- :subname="licenseText"
- :subtitle="licenseText"
- @close="hideAppDetails">
- <!-- Fallback icon incase no app icon is available -->
- <template v-if="!hasScreenshot" #header>
- <NcIconSvgWrapper class="app-sidebar__fallback-icon"
- :svg="appIcon ?? ''"
- :size="64" />
- </template>
-
- <template #description>
- <!-- Featured/Supported badges -->
- <div class="app-sidebar__badges">
- <AppLevelBadge :level="app.level" />
- <AppScore v-if="hasRating" :score="rating" />
- </div>
- </template>
-
- <!-- Tab content -->
- <AppDescriptionTab :app="app" />
- <AppDetailsTab :app="app" />
- <AppReleasesTab :app="app" />
- </NcAppSidebar>
-</template>
-
-<script setup lang="ts">
-import { translate as t } from '@nextcloud/l10n'
-import { computed, onMounted, ref, watch } from 'vue'
-import { useRoute, useRouter } from 'vue-router/composables'
-import { useAppsStore } from '../store/apps-store'
-
-import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import AppScore from '../components/AppList/AppScore.vue'
-import AppDescriptionTab from '../components/AppStoreSidebar/AppDescriptionTab.vue'
-import AppDetailsTab from '../components/AppStoreSidebar/AppDetailsTab.vue'
-import AppReleasesTab from '../components/AppStoreSidebar/AppReleasesTab.vue'
-import AppLevelBadge from '../components/AppList/AppLevelBadge.vue'
-import { useAppIcon } from '../composables/useAppIcon.ts'
-
-const route = useRoute()
-const router = useRouter()
-const store = useAppsStore()
-
-const appId = computed(() => route.params.id ?? '')
-const app = computed(() => store.getAppById(appId.value)!)
-const hasRating = computed(() => app.value.appstoreData?.ratingNumOverall > 5)
-const rating = computed(() => app.value.appstoreData?.ratingNumRecent > 5
- ? app.value.appstoreData.ratingRecent
- : (app.value.appstoreData?.ratingOverall ?? 0.5))
-const showSidebar = computed(() => app.value !== null)
-
-const { appIcon } = useAppIcon(app)
-
-/**
- * The second text line shown on the sidebar
- */
-const licenseText = computed(() => app.value ? t('settings', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.licence.toString().toUpperCase() }) : '')
-
-const activeTab = ref('details')
-watch([app], () => { activeTab.value = 'details' })
-
-/**
- * Hide the details sidebar by pushing a new route
- */
-const hideAppDetails = () => {
- router.push({
- name: 'apps-category',
- params: { category: route.params.category },
- })
-}
-
-/**
- * Whether the app screenshot is loaded
- */
-const screenshotLoaded = ref(false)
-const hasScreenshot = computed(() => app.value?.screenshot && screenshotLoaded.value)
-/**
- * Preload the app screenshot
- */
-const loadScreenshot = () => {
- if (app.value?.releases && app.value?.screenshot) {
- const image = new Image()
- image.onload = () => {
- screenshotLoaded.value = true
- }
- image.src = app.value.screenshot
- }
-}
-// Watch app and set screenshot loaded when
-watch([app], loadScreenshot)
-onMounted(loadScreenshot)
-</script>
-
-<style scoped lang="scss">
-.app-sidebar {
- // If a screenshot is available it should cover the whole figure
- &--with-screenshot {
- :deep(.app-sidebar-header__figure) {
- background-size: cover;
- }
- }
-
- &__fallback-icon {
- // both 100% to center the icon
- width: 100%;
- height: 100%;
- }
-
- &__badges {
- display: flex;
- flex-direction: row;
- gap: 12px;
- }
-
- &__version {
- color: var(--color-text-maxcontrast);
- }
-}
-</style>