diff options
Diffstat (limited to 'apps/settings/src/components/AppList')
-rw-r--r-- | apps/settings/src/components/AppList/AppDaemonBadge.vue | 37 | ||||
-rw-r--r-- | apps/settings/src/components/AppList/AppItem.vue | 456 | ||||
-rw-r--r-- | apps/settings/src/components/AppList/AppLevelBadge.vue | 56 | ||||
-rw-r--r-- | apps/settings/src/components/AppList/AppScore.vue | 92 |
4 files changed, 505 insertions, 136 deletions
diff --git a/apps/settings/src/components/AppList/AppDaemonBadge.vue b/apps/settings/src/components/AppList/AppDaemonBadge.vue new file mode 100644 index 00000000000..ca81e7fab0b --- /dev/null +++ b/apps/settings/src/components/AppList/AppDaemonBadge.vue @@ -0,0 +1,37 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <span v-if="daemon" + class="app-daemon-badge" + :title="daemon.name"> + <NcIconSvgWrapper :path="mdiFileChart" :size="20" inline /> + {{ daemon.display_name }} + </span> +</template> + +<script setup lang="ts"> +import type { IDeployDaemon } from '../../app-types.ts' +import { mdiFileChart } from '@mdi/js' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +defineProps<{ + daemon?: IDeployDaemon +}>() +</script> + +<style scoped lang="scss"> +.app-daemon-badge { + color: var(--color-text-maxcontrast); + background-color: transparent; + border: 1px solid var(--color-text-maxcontrast); + border-radius: var(--border-radius); + + display: flex; + flex-direction: row; + gap: 6px; + padding: 3px 6px; + width: fit-content; +} +</style> diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue index f6431d64e18..95a98a93cde 100644 --- a/apps/settings/src/components/AppList/AppItem.vue +++ b/apps/settings/src/components/AppList/AppItem.vue @@ -1,141 +1,204 @@ <!-- - - @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"> - <defs><filter :id="filterId"><feColorMatrix in="SourceGraphic" type="matrix" values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0" /></filter></defs> <image x="0" y="0" width="32" height="32" preserveAspectRatio="xMinYMin meet" - :filter="filterUrl" :xlink:href="app.preview" 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" - v-tooltip.auto="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" - v-tooltip.auto="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" /> - <input v-if="app.update" - class="update primary" - type="button" - :value="t('settings', 'Update to {update}', {update:app.update})" - :disabled="installing || isLoading" + <div v-if="isLoading || isInitializing" class="icon icon-loading-small" /> + <NcButton v-if="app.update" + type="primary" + :disabled="installing || isLoading || !defaultDeployDaemonAccessible || isManualInstall" + :title="updateButtonText" @click.stop="update(app.id)"> - <input v-if="app.canUnInstall" + {{ t('settings', 'Update to {update}', {update:app.update}) }} + </NcButton> + <NcButton v-if="app.canUnInstall" class="uninstall" - type="button" - :value="t('settings', 'Remove')" + type="tertiary" :disabled="installing || isLoading" @click.stop="remove(app.id)"> - <input v-if="app.active" - class="enable" - type="button" - :value="t('settings','Disable')" - :disabled="installing || isLoading" + {{ t('settings', 'Remove') }} + </NcButton> + <NcButton v-if="app.active" + :disabled="installing || isLoading || isInitializing || isDeploying" @click.stop="disable(app.id)"> - <input v-if="!app.active && (app.canInstall || app.isCompatible)" - v-tooltip.auto="enableButtonTooltip" - class="enable" - type="button" - :value="enableButtonText" - :disabled="!app.canInstall || installing || isLoading" - @click.stop="enable(app.id)"> - <input v-else-if="!app.active" - v-tooltip.auto="forceEnableButtonTooltip" - class="enable force" - type="button" - :value="forceEnableButtonText" - :disabled="installing || isLoading" + {{ disableButtonText }} + </NcButton> + <NcButton v-if="!app.active && (app.canInstall || app.isCompatible)" + :title="enableButtonTooltip" + :aria-label="enableButtonTooltip" + type="primary" + :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 || !defaultDeployDaemonAccessible" @click.stop="forceEnable(app.id)"> - </div> - </div> + {{ forceEnableButtonText }} + </NcButton> + + <DaemonSelectionDialog v-if="app?.app_api && showSelectDaemonModal" + :show.sync="showSelectDaemonModal" + :app="app" /> + </component> + </component> </template> <script> -import AppScore from './AppScore' -import AppManagement from '../../mixins/AppManagement' -import SvgFilterMixin from '../SvgFilterMixin' +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/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) { @@ -146,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 @@ -156,36 +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> - .force { - background: var(--color-main-background); - border-color: var(--color-error); - color: var(--color-error); +<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); } - .force:hover, - .force:active { - background: var(--color-error); - border-color: var(--color-error) !important; - color: var(--color-main-background); + + &--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> diff --git a/apps/settings/src/components/AppList/AppLevelBadge.vue b/apps/settings/src/components/AppList/AppLevelBadge.vue new file mode 100644 index 00000000000..8461f5eb6b9 --- /dev/null +++ b/apps/settings/src/components/AppList/AppLevelBadge.vue @@ -0,0 +1,56 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <span v-if="isSupported || isFeatured" + class="app-level-badge" + :class="{ 'app-level-badge--supported': isSupported }" + :title="badgeTitle"> + <NcIconSvgWrapper :path="badgeIcon" :size="20" inline /> + {{ badgeText }} + </span> +</template> + +<script setup lang="ts"> +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +import { mdiCheck, mdiStarShootingOutline } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { computed } from 'vue' + +const props = defineProps<{ + /** + * The app level + */ + level?: number +}>() + +const isSupported = computed(() => props.level === 300) +const isFeatured = computed(() => props.level === 200) +const badgeIcon = computed(() => isSupported.value ? mdiStarShootingOutline : mdiCheck) +const badgeText = computed(() => isSupported.value ? t('settings', 'Supported') : t('settings', 'Featured')) +const badgeTitle = computed(() => isSupported.value + ? t('settings', 'This app is supported via your current Nextcloud subscription.') + : t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.')) +</script> + +<style scoped lang="scss"> +.app-level-badge { + color: var(--color-text-maxcontrast); + background-color: transparent; + border: 1px solid var(--color-text-maxcontrast); + border-radius: var(--border-radius); + + display: flex; + flex-direction: row; + gap: 6px; + padding: 3px 6px; + width: fit-content; + + &--supported { + border-color: var(--color-success); + color: var(--color-success); + } +} +</style> diff --git a/apps/settings/src/components/AppList/AppScore.vue b/apps/settings/src/components/AppList/AppScore.vue index 0569d687e88..a1dd4c03842 100644 --- a/apps/settings/src/components/AppList/AppScore.vue +++ b/apps/settings/src/components/AppList/AppScore.vue @@ -1,40 +1,72 @@ <!-- - - @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> - <img :src="scoreImage" class="app-score-image"> + <span role="img" + :aria-label="title" + :title="title" + class="app-score__wrapper"> + <NcIconSvgWrapper v-for="index in fullStars" + :key="`full-star-${index}`" + :path="mdiStar" + inline /> + <NcIconSvgWrapper v-if="hasHalfStar" :path="mdiStarHalfFull" inline /> + <NcIconSvgWrapper v-for="index in emptyStars" + :key="`empty-star-${index}`" + :path="mdiStarOutline" + inline /> + </span> </template> -<script> -import { imagePath } from '@nextcloud/router' +<script lang="ts"> +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { mdiStar, mdiStarHalfFull, mdiStarOutline } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' -export default { +export default defineComponent({ name: 'AppScore', - props: ['score'], + components: { + NcIconSvgWrapper, + }, + props: { + score: { + type: Number, + required: true, + }, + }, + setup() { + return { + mdiStar, + mdiStarHalfFull, + mdiStarOutline, + } + }, computed: { - scoreImage() { - const score = Math.round(this.score * 10) - const imageName = 'rating/s' + score + '.svg' - return imagePath('core', imageName) + title() { + const appScore = (this.score * 5).toFixed(1) + return t('settings', 'Community rating: {score}/5', { score: appScore }) + }, + fullStars() { + return Math.floor(this.score * 5 + 0.25) + }, + emptyStars() { + return Math.min(Math.floor((1 - this.score) * 5 + 0.25), 5 - this.fullStars) + }, + hasHalfStar() { + return (this.fullStars + this.emptyStars) < 5 }, }, -} +}) </script> +<style scoped> +.app-score__wrapper { + display: inline-flex; + color: var(--color-favorite, #a08b00); + + > * { + vertical-align: text-bottom; + } +} +</style> |