diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-02-22 16:43:45 +0100 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-03-11 16:02:34 +0100 |
commit | 84cb04f7c068defef353db8823ca8666d85874af (patch) | |
tree | dbbe4e5faadf67ae6c9a008d5b96907a83510980 /apps | |
parent | 289e43a54858878b0f2b7e5dd0536787351fe52a (diff) | |
download | nextcloud-server-84cb04f7c068defef353db8823ca8666d85874af.tar.gz nextcloud-server-84cb04f7c068defef353db8823ca8666d85874af.zip |
feat: Make appstore sidebar tabs standalone components
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps')
5 files changed, 635 insertions, 267 deletions
diff --git a/apps/settings/src/components/AppDetails.vue b/apps/settings/src/components/AppDetails.vue deleted file mode 100644 index c17adc6b230..00000000000 --- a/apps/settings/src/components/AppDetails.vue +++ /dev/null @@ -1,262 +0,0 @@ -<!-- - - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - 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> - <div class="app-details"> - <div class="app-details__actions"> - <div v-if="app.active && canLimitToGroups(app)" class="app-details__actions-groups"> - <input :id="prefix('groups_enable', app.id)" - v-model="groupCheckedAppsData" - type="checkbox" - :value="app.id" - class="groups-enable__checkbox checkbox" - @change="setGroupLimit"> - <label :for="prefix('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> - </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"> - {{ 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> - - <p class="app-details__documentation"> - <a v-if="!app.internal" - class="appslink" - :href="appstoreUrl" - target="_blank" - rel="noreferrer noopener">{{ t('settings', 'View in store') }} ↗</a> - - <a v-if="app.website" - class="appslink" - :href="app.website" - target="_blank" - rel="noreferrer noopener">{{ t('settings', 'Visit website') }} ↗</a> - <a v-if="app.bugs" - class="appslink" - :href="app.bugs" - target="_blank" - rel="noreferrer noopener">{{ t('settings', 'Report a bug') }} ↗</a> - - <a v-if="app.documentation && app.documentation.user" - class="appslink" - :href="app.documentation.user" - target="_blank" - rel="noreferrer noopener">{{ t('settings', 'Usage documentation') }} ↗</a> - <a v-if="app.documentation && app.documentation.admin" - class="appslink" - :href="app.documentation.admin" - target="_blank" - rel="noreferrer noopener">{{ t('settings', 'Admin documentation') }} ↗</a> - <a v-if="app.documentation && app.documentation.developer" - class="appslink" - :href="app.documentation.developer" - target="_blank" - rel="noreferrer noopener">{{ t('settings', 'Developer documentation') }} ↗</a> - </p> - <Markdown class="app-details__description" :min-heading="2" :text="app.description" /> - </div> -</template> - -<script> -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' - -import AppManagement from '../mixins/AppManagement.js' -import PrefixMixin from './PrefixMixin.vue' -import Markdown from './Markdown.vue' - -export default { - name: 'AppDetails', - - components: { - NcSelect, - Markdown, - }, - mixins: [AppManagement, PrefixMixin], - - props: { - app: { - type: Object, - required: true, - }, - }, - - data() { - return { - groupCheckedAppsData: false, - } - }, - - computed: { - appstoreUrl() { - return `https://apps.nextcloud.com/apps/${this.app.id}` - }, - licence() { - if (this.app.licence) { - return t('settings', '{license}-licensed', { license: ('' + this.app.licence).toUpperCase() }) - } - return null - }, - author() { - if (typeof this.app.author === 'string') { - return [ - { - '@value': this.app.author, - }, - ] - } - if (this.app.author['@value']) { - return [this.app.author] - } - return this.app.author - }, - 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 - } - }, -} -</script> - -<style scoped lang="scss"> -.app-details { - padding: 20px; - - &__actions { - // app management - &-manage { - // if too many, shrink them and ellipsis - display: flex; - input { - flex: 0 1 auto; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - } - } - &__dependencies { - opacity: .7; - } - &__documentation { - padding-top: 20px; - a.appslink { - display: block; - } - } - &__description { - padding-top: 20px; - } -} - -.force { - color: var(--color-error); - border-color: var(--color-error); - background: var(--color-main-background); -} -.force:hover, -.force:active { - color: var(--color-main-background); - border-color: var(--color-error) !important; - background: var(--color-error); -} - -</style> diff --git a/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue new file mode 100644 index 00000000000..0466ddf4f4c --- /dev/null +++ b/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue @@ -0,0 +1,55 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - 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> + <NcAppSidebarTab id="desc" + :name="t('settings', 'Description')" + :order="0"> + <template #icon> + <NcIconSvgWrapper :path="mdiTextShort" /> + </template> + <div class="app-description"> + <Markdown :text="app.description" :min-heading="4" /> + </div> + </NcAppSidebarTab> +</template> + +<script setup lang="ts"> +import type { IAppstoreApp } from '../../app-types' + +import { mdiTextShort } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' + +import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import Markdown from '../Markdown.vue' + +defineProps<{ + app: IAppstoreApp, +}>() +</script> + +<style scoped lang="scss"> +.app-description { + padding: 12px; +} +</style> diff --git a/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue new file mode 100644 index 00000000000..b9fad458300 --- /dev/null +++ b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue @@ -0,0 +1,429 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - 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> + <NcAppSidebarTab id="details" + :name="t('settings', 'Details')" + :order="1"> + <template #icon> + <NcIconSvgWrapper :path="mdiTextBox" /> + </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> + </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"> + {{ 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" class="app-details__section"> + <h4> + {{ t('settings', 'Latest updated') }} + </h4> + <NcDateTime :timestamp="lastModified" /> + </div> + + <div class="app-details__section"> + <h4> + {{ t('settings', 'Author') }} + </h4> + <p class="app-details__authors"> + {{ appAuthors }} + </p> + </div> + + <div class="app-details__section"> + <h4> + {{ t('settings', 'Categories') }} + </h4> + <p> + {{ appCategories }} + </p> + </div> + + <div v-if="externalResources.length > 0" class="app-details__section"> + <h4>{{ t('settings', 'Resources') }}</h4> + <ul class="app-details__documentation" :aria-label="t('settings', 'Documentation')"> + <li v-for="resource of externalResources" :key="resource.id"> + <a class="appslink" + :href="resource.href" + target="_blank" + rel="noreferrer noopener"> + {{ resource.label }} ↗ + </a> + </li> + </ul> + </div> + + <div class="app-details__section"> + <h4>{{ t('settings', 'Interact') }}</h4> + <div class="app-details__interact"> + <NcButton :disabled="!app.bugs" + :href="app.bugs ?? '#'" + :aria-label="t('settings', 'Report a bug')" + :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')" + :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')" + :title="t('settings', 'Ask questions or discuss')"> + <template #icon> + <NcIconSvgWrapper :path="mdiTooltipQuestion" /> + </template> + </NcButton> + <NcButton v-if="!app.internal" + :href="rateAppUrl" + :aria-label="t('settings', 'Rate the app')" + :title="t('settings', 'Rate')"> + <template #icon> + <NcIconSvgWrapper :path="mdiStar" /> + </template> + </NcButton> + </div> + </div> + </div> + </NcAppSidebarTab> +</template> + +<script> +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, + } + }, + + data() { + return { + groupCheckedAppsData: false, + } + }, + + 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 + } + }, +} +</script> + +<style scoped lang="scss"> +.app-details { + padding: 20px; + + &__actions { + // app management + &-manage { + // if too many, shrink them and ellipsis + display: flex; + input { + flex: 0 1 auto; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + } + } + &__authors { + color: var(--color-text-maxcontrast); + } + + &__section { + margin-top: 15px; + + h4 { + font-size: 16px; + font-weight: bold; + margin-block-end: 5px; + } + } + + &__interact { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 12px; + } + + &__documentation { + a { + text-decoration: underline; + } + li { + padding-inline-start: 20px; + + &::before { + width: 5px; + height: 5px; + border-radius: 100%; + background-color: var(--color-main-text); + content: ""; + float: inline-start; + margin-inline-start: -13px; + position: relative; + top: 10px; + } + } + } +} + +.force { + color: var(--color-error); + border-color: var(--color-error); + background: var(--color-main-background); +} +.force:hover, +.force:active { + color: var(--color-main-background); + border-color: var(--color-error) !important; + background: var(--color-error); +} + +</style> diff --git a/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue b/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue index cd520220337..8fe89221170 100644 --- a/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue +++ b/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue @@ -22,15 +22,16 @@ --> <template> <NcAppSidebarTab v-if="hasChangelog" - id="desca" + id="changelog" :name="t('settings', 'Changelog')" - :order="1"> + :order="2"> <template #icon> <NcIconSvgWrapper :path="mdiClockFast" :size="24" /> </template> <div v-for="release in app.releases" :key="release.version" class="app-sidebar-tabs__release"> <h2>{{ release.version }}</h2> - <Markdown class="app-sidebar-tabs__release-text" :text="createChangelogFromRelease(release)" /> + <Markdown class="app-sidebar-tabs__release-text" + :text="createChangelogFromRelease(release)" /> </div> </NcAppSidebarTab> </template> @@ -40,16 +41,15 @@ import type { IAppstoreApp, IAppstoreAppRelease } from '../../app-types.ts' import { mdiClockFast } from '@mdi/js' import { getLanguage, translate as t } from '@nextcloud/l10n' +import { computed } from 'vue' import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import Markdown from '../Markdown.vue' -import { computed, watch } from 'vue' // eslint-disable-next-line @typescript-eslint/no-unused-vars const props = defineProps<{ app: IAppstoreApp }>() -watch([props], () => console.warn(props.app.releases)) const hasChangelog = computed(() => Object.values(props.app.releases[0]?.translations ?? {}).some(({ changelog }) => !!changelog)) const createChangelogFromRelease = (release: IAppstoreAppRelease) => release.translations?.[getLanguage()]?.changelog ?? release.translations?.en?.changelog ?? '' diff --git a/apps/settings/src/views/AppStoreSidebar.vue b/apps/settings/src/views/AppStoreSidebar.vue new file mode 100644 index 00000000000..dda9afd21f8 --- /dev/null +++ b/apps/settings/src/views/AppStoreSidebar.vue @@ -0,0 +1,146 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - @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> + <!-- Selected app details --> + <NcAppSidebar v-if="showSidebar" + class="app-sidebar" + :class="{ 'app-sidebar--with-screenshot': hasScreenshot }" + :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) + +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() }) : '') + +/** + * 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> |