]> source.dussan.org Git - nextcloud-server.git/commitdiff
refactor(app-store): Split the sidebar into smaller components
authorFerdinand Thiessen <opensource@fthiessen.de>
Wed, 23 Oct 2024 10:46:20 +0000 (12:46 +0200)
committerFerdinand Thiessen <opensource@fthiessen.de>
Wed, 23 Oct 2024 11:01:28 +0000 (13:01 +0200)
This also fixes reactivity bugs

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
apps/settings/src/components/AppStore/AppStoreSidebar/AppDescriptionTab.vue
apps/settings/src/components/AppStore/AppStoreSidebar/AppDetailsTab.vue
apps/settings/src/components/AppStore/AppStoreSidebar/AppDetailsTabLimitGroups.vue [new file with mode: 0644]
apps/settings/src/composables/useAppIcon.ts [deleted file]
apps/settings/src/views/AppStore/AppStoreSidebar.vue [new file with mode: 0644]
apps/settings/src/views/AppStoreSidebar.vue [deleted file]

index 4b94be27959bee2cb1789c6d2ca4bdf1e4c6feb6..d42de009af8b0a7cdd862315704bc6d4a4a0dcb6 100644 (file)
@@ -17,7 +17,7 @@
 </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'
@@ -27,7 +27,7 @@ import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js
 import Markdown from '../../Markdown.vue'
 
 defineProps<{
-       app: IAppstoreApp,
+       app: IAppStoreApp,
 }>()
 </script>
 
index b94a2ae64f01e7dd69f775953daacc9e00790b8b..090d6b521e99f48d56a15933caa01ecde0d4a273 100644 (file)
@@ -2,7 +2,6 @@
   - 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;
@@ -354,17 +271,20 @@ export default {
                        }
                }
        }
-       &__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);
                }
        }
 
diff --git a/apps/settings/src/components/AppStore/AppStoreSidebar/AppDetailsTabLimitGroups.vue b/apps/settings/src/components/AppStore/AppStoreSidebar/AppDetailsTabLimitGroups.vue
new file mode 100644 (file)
index 0000000..3537e7b
--- /dev/null
@@ -0,0 +1,126 @@
+<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>
diff --git a/apps/settings/src/composables/useAppIcon.ts b/apps/settings/src/composables/useAppIcon.ts
deleted file mode 100644 (file)
index 76efea2..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * 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,
-       }
-}
diff --git a/apps/settings/src/views/AppStore/AppStoreSidebar.vue b/apps/settings/src/views/AppStore/AppStoreSidebar.vue
new file mode 100644 (file)
index 0000000..5039d19
--- /dev/null
@@ -0,0 +1,121 @@
+<!--
+  - 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>
diff --git a/apps/settings/src/views/AppStoreSidebar.vue b/apps/settings/src/views/AppStoreSidebar.vue
deleted file mode 100644 (file)
index 5811b26..0000000
+++ /dev/null
@@ -1,134 +0,0 @@
-<!--
-  - 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>