diff options
Diffstat (limited to 'apps/settings/src/components/AppList/AppItem.vue')
-rw-r--r-- | apps/settings/src/components/AppList/AppItem.vue | 406 |
1 files changed, 324 insertions, 82 deletions
diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue index cede668d51f..95a98a93cde 100644 --- a/apps/settings/src/components/AppList/AppItem.vue +++ b/apps/settings/src/components/AppList/AppItem.vue @@ -1,31 +1,26 @@ <!-- - - @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/>. - - - --> - + - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div class="section" :class="{ selected: isSelected }" @click="showAppDetails"> - <div class="app-image app-image-icon" @click="showAppDetails"> - <div v-if="(listView && !app.preview) || (!listView && !screenshotLoaded)" class="icon-settings-dark" /> + <component :is="listView ? 'tr' : (inline ? 'article' : 'li')" + class="app-item" + :class="{ + 'app-item--list-view': listView, + 'app-item--store-view': !listView, + 'app-item--selected': isSelected, + 'app-item--with-sidebar': withSidebar, + }"> + <component :is="dataItemTag" + class="app-image app-image-icon" + :headers="getDataItemHeaders(`app-table-col-icon`)"> + <div v-if="!app?.app_api && shouldDisplayDefaultIcon" class="icon-settings-dark" /> + <NcIconSvgWrapper v-else-if="app.app_api && shouldDisplayDefaultIcon" + :path="mdiCogOutline" + :size="listView ? 24 : 48" + style="min-width: auto; min-height: auto; height: 100%;" /> - <svg v-else-if="listView && app.preview" + <svg v-else-if="listView && app.preview && !app.app_api" width="32" height="32" viewBox="0 0 32 32"> @@ -38,41 +33,53 @@ class="app-icon" /> </svg> - <img v-if="!listView && app.screenshot && screenshotLoaded" :src="app.screenshot" width="100%"> - </div> - <div class="app-name" @click="showAppDetails"> - {{ app.name }} - </div> - <div v-if="!listView" class="app-summary"> + <img v-if="!listView && app.screenshot && screenshotLoaded" :src="app.screenshot" alt=""> + </component> + <component :is="dataItemTag" + class="app-name" + :headers="getDataItemHeaders(`app-table-col-name`)"> + <router-link class="app-name--link" + :to="{ + name: 'apps-details', + params: { + category: category, + id: app.id + }, + }" + :aria-label="t('settings', 'Show details for {appName} app', { appName:app.name })"> + {{ app.name }} + </router-link> + </component> + <component :is="dataItemTag" + v-if="!listView" + class="app-summary" + :headers="getDataItemHeaders(`app-version`)"> {{ app.summary }} - </div> - <div v-if="listView" class="app-version"> + </component> + <component :is="dataItemTag" + v-if="listView" + class="app-version" + :headers="getDataItemHeaders(`app-table-col-version`)"> <span v-if="app.version">{{ app.version }}</span> <span v-else-if="app.appstoreData.releases[0].version">{{ app.appstoreData.releases[0].version }}</span> - </div> - - <div class="app-level"> - <span v-if="app.level === 300" - :title="t('settings', 'This app is supported via your current Nextcloud subscription.')" - :aria-label="t('settings', 'This app is supported via your current Nextcloud subscription.')" - class="supported icon-checkmark-color"> - {{ t('settings', 'Supported') }}</span> - <span v-if="app.level === 200" - :title="t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.')" - :aria-label="t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.')" - class="official icon-checkmark"> - {{ t('settings', 'Featured') }}</span> - <AppScore v-if="hasRating && !listView" :score="app.score" /> - </div> + </component> - <div class="actions"> + <component :is="dataItemTag" :headers="getDataItemHeaders(`app-table-col-level`)" class="app-level"> + <AppLevelBadge :level="app.level" /> + <AppScore v-if="hasRating && !listView" :score="app.score" /> + </component> + <component :is="dataItemTag" + v-if="!inline" + :headers="getDataItemHeaders(`app-table-col-actions`)" + class="app-actions"> <div v-if="app.error" class="warning"> {{ app.error }} </div> - <div v-if="isLoading" class="icon icon-loading-small" /> + <div v-if="isLoading || isInitializing" class="icon icon-loading-small" /> <NcButton v-if="app.update" type="primary" - :disabled="installing || isLoading" + :disabled="installing || isLoading || !defaultDeployDaemonAccessible || isManualInstall" + :title="updateButtonText" @click.stop="update(app.id)"> {{ t('settings', 'Update to {update}', {update:app.update}) }} </NcButton> @@ -84,62 +91,114 @@ {{ t('settings', 'Remove') }} </NcButton> <NcButton v-if="app.active" - :disabled="installing || isLoading" + :disabled="installing || isLoading || isInitializing || isDeploying" @click.stop="disable(app.id)"> - {{ t('settings','Disable') }} + {{ disableButtonText }} </NcButton> <NcButton v-if="!app.active && (app.canInstall || app.isCompatible)" :title="enableButtonTooltip" :aria-label="enableButtonTooltip" type="primary" - :disabled="!app.canInstall || installing || isLoading" - @click.stop="enable(app.id)"> + :disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying" + @click.stop="enableButtonAction"> {{ enableButtonText }} </NcButton> <NcButton v-else-if="!app.active" :title="forceEnableButtonTooltip" :aria-label="forceEnableButtonTooltip" type="secondary" - :disabled="installing || isLoading" + :disabled="installing || isLoading || !defaultDeployDaemonAccessible" @click.stop="forceEnable(app.id)"> {{ forceEnableButtonText }} </NcButton> - </div> - </div> + + <DaemonSelectionDialog v-if="app?.app_api && showSelectDaemonModal" + :show.sync="showSelectDaemonModal" + :app="app" /> + </component> + </component> </template> <script> +import { useAppsStore } from '../../store/apps-store.js' + import AppScore from './AppScore.vue' +import AppLevelBadge from './AppLevelBadge.vue' import AppManagement from '../../mixins/AppManagement.js' import SvgFilterMixin from '../SvgFilterMixin.vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { mdiCogOutline } from '@mdi/js' +import { useAppApiStore } from '../../store/app-api-store.ts' +import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue' export default { name: 'AppItem', components: { + AppLevelBadge, AppScore, NcButton, + NcIconSvgWrapper, + DaemonSelectionDialog, }, mixins: [AppManagement, SvgFilterMixin], props: { - app: {}, - category: {}, + app: { + type: Object, + required: true, + }, + category: { + type: String, + required: true, + }, listView: { type: Boolean, default: true, }, + useBundleView: { + type: Boolean, + default: false, + }, + headers: { + type: String, + default: null, + }, + inline: { + type: Boolean, + default: false, + }, + }, + setup() { + const store = useAppsStore() + const appApiStore = useAppApiStore() + + return { + store, + appApiStore, + mdiCogOutline, + } }, data() { return { isSelected: false, scrolled: false, screenshotLoaded: false, + showSelectDaemonModal: false, } }, computed: { hasRating() { return this.app.appstoreData && this.app.appstoreData.ratingNumOverall > 5 }, + dataItemTag() { + return this.listView ? 'td' : 'div' + }, + withSidebar() { + return !!this.$route.params.id + }, + shouldDisplayDefaultIcon() { + return (this.listView && !this.app.preview) || (!this.listView && !this.screenshotLoaded) + }, }, watch: { '$route.params.id'(id) { @@ -150,7 +209,7 @@ export default { this.isSelected = (this.app.id === this.$route.params.id) if (this.app.releases && this.app.screenshot) { const image = new Image() - image.onload = (e) => { + image.onload = () => { this.screenshotLoaded = true } image.src = this.app.screenshot @@ -160,34 +219,217 @@ export default { }, methods: { - async showAppDetails(event) { - if (event.currentTarget.tagName === 'INPUT' || event.currentTarget.tagName === 'A') { + prefix(prefix, content) { + return prefix + '_' + content + }, + + getDataItemHeaders(columnName) { + return this.useBundleView ? [this.headers, columnName].join(' ') : null + }, + showSelectionModal() { + this.showSelectDaemonModal = true + }, + async enableButtonAction() { + if (!this.app?.app_api) { + this.enable(this.app.id) return } - try { - await this.$router.push({ - name: 'apps-details', - params: { category: this.category, id: this.app.id }, - }) - } catch (e) { - // we already view this app + await this.appApiStore.fetchDockerDaemons() + if (this.appApiStore.dockerDaemons.length === 1 && this.app.needsDownload) { + this.enable(this.app.id, this.appApiStore.dockerDaemons[0]) + } else if (this.app.needsDownload) { + this.showSelectionModal() + } else { + this.enable(this.app.id, this.app.daemon) } }, - prefix(prefix, content) { - return prefix + '_' + content - }, }, } </script> -<style scoped> - .app-icon { - filter: var(--background-invert-if-bright); +<style scoped lang="scss"> +@use '../../../../../core/css/variables.scss' as variables; +@use 'sass:math'; + +.app-item { + position: relative; + + &:hover { + background-color: var(--color-background-dark); } - .actions { - display: flex !important; - gap: 8px; - flex-wrap: wrap; - justify-content: end; + + &--list-view { + --app-item-padding: calc(var(--default-grid-baseline) * 2); + --app-item-height: calc(var(--default-clickable-area) + var(--app-item-padding) * 2); + + &.app-item--selected { + background-color: var(--color-background-dark); + } + + > * { + vertical-align: middle; + border-bottom: 1px solid var(--color-border); + padding: var(--app-item-padding); + height: var(--app-item-height); + } + + .app-image { + width: var(--default-clickable-area); + height: auto; + text-align: end; + } + + .app-image-icon svg, + .app-image-icon .icon-settings-dark { + margin-top: 5px; + width: 20px; + height: 20px; + opacity: .5; + background-size: cover; + display: inline-block; + } + + .app-name { + padding: 0 var(--app-item-padding); + } + + .app-name--link { + height: var(--app-item-height); + display: flex; + align-items: center; + } + + // Note: because of Safari bug, we cannot position link overlay relative to the table row + // So we need to manually position it relative to the table container and cell + // See: https://bugs.webkit.org/show_bug.cgi?id=240961 + .app-name--link::after { + content: ''; + position: absolute; + inset-inline: 0; + height: var(--app-item-height); + } + + .app-actions { + display: flex; + gap: var(--app-item-padding); + flex-wrap: wrap; + justify-content: end; + + .icon-loading-small { + display: inline-block; + top: 4px; + margin-inline-end: 10px; + } + } + + /* hide app version and level on narrower screens */ + @media only screen and (max-width: 900px) { + .app-version, + .app-level { + display: none; + } + } + + /* Hide actions on a small screen. Click on app opens fill-screen sidebar with the buttons */ + @media only screen and (max-width: math.div(variables.$breakpoint-mobile, 2)) { + .app-actions { + display: none; + } + } + } + + &--store-view { + padding: 30px; + + .app-image-icon .icon-settings-dark { + width: 100%; + height: 150px; + background-size: 45px; + opacity: 0.5; + } + + .app-image-icon svg { + position: absolute; + bottom: 43px; + /* position halfway vertically */ + width: 64px; + height: 64px; + opacity: .1; + } + + .app-name { + margin: 5px 0; + } + + .app-name--link::after { + content: ''; + position: absolute; + inset-block: 0; + inset-inline: 0; + } + + .app-actions { + margin: 10px 0; + } + + @media only screen and (min-width: 1601px) { + width: 25%; + + &.app-item--with-sidebar { + width: 33%; + } + } + + @media only screen and (max-width: 1600px) { + width: 25%; + + &.app-item--with-sidebar { + width: 33%; + } + } + + @media only screen and (max-width: 1400px) { + width: 33%; + + &.app-item--with-sidebar { + width: 50%; + } + } + + @media only screen and (max-width: 900px) { + width: 50%; + + &.app-item--with-sidebar { + width: 100%; + } + } + + @media only screen and (max-width: variables.$breakpoint-mobile) { + width: 50%; + } + + @media only screen and (max-width: 480px) { + width: 100%; + } + } +} + +.app-icon { + filter: var(--background-invert-if-bright); +} + +.app-image { + position: relative; + height: 150px; + opacity: 1; + overflow: hidden; + + img { + width: 100%; } +} + +.app-version { + color: var(--color-text-maxcontrast); +} </style> |