'canInstall' => true,
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
'score' => $app['ratingOverall'],
+ 'ratingRecent' => $app['ratingRecent'],
'ratingNumOverall' => $app['ratingNumOverall'],
- 'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
- 'removable' => $existsLocally,
+ 'ratingNumRecent' => $app['ratingNumRecent'],
'active' => $this->appManager->isEnabledForUser($app['id']),
'needsDownload' => !$existsLocally,
'groups' => $groups,
'app-item--list-view': listView,
'app-item--store-view': !listView,
'app-item--selected': isSelected,
- 'app-item--with-sidebar': withSidebar,
}">
<AppItemIcon :app="app"
:list-view="listView"
<component :is="dataItemTag"
v-if="!listView"
- class="app-summary"
+ class="app-item__summary"
:headers="getDataItemHeaders(`app-version`)">
{{ app.summary }}
</component>
<component :is="dataItemTag"
v-if="listView"
- class="app-version"
+ v-show="!isSmallMobile"
+ class="app-item__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>
+ <span v-else-if="app.releases?.[0].version">{{ app?.releases[0].version }}</span>
</component>
- <component :is="dataItemTag" :headers="getDataItemHeaders(`app-table-col-level`)" class="app-level">
+ <component
+ :is="dataItemTag"
+ v-show="!listView || !isSmallMobile"
+ class="app-item__level"
+ :headers="getDataItemHeaders(`app-table-col-level`)">
<AppLevelBadge :level="app.level" />
- <AppScore v-if="hasRating && !listView" :score="app.score" />
+ <AppScore v-if="appRating && !listView" :score="appRating" />
</component>
<AppItemActions v-if="!inline"
- v-show="!isSmallMobile"
:app="app"
+ :data-item-tag="dataItemTag"
+ :list-view="listView"
:headers="getDataItemHeaders('app-table-col-actions')" />
</component>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
-import type { IAppstoreApp } from '../../../app-types'
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes'
import { useIsSmallMobile } from '@nextcloud/vue/dist/Composables/useIsMobile.js'
import { useRoute } from 'vue-router/composables'
-import { computed } from 'vue'
+import { computed, toRef } from 'vue'
+import { useAppRating } from '../../../composables/useAppRating'
import AppItemActions from './AppItemActions.vue'
import AppItemIcon from './AppItemIcon.vue'
* The app to show
*/
app: {
- type: Object as PropType<IAppstoreApp>,
+ type: Object as PropType<IAppStoreApp>,
required: true,
},
const route = useRoute()
const isSmallMobile = useIsSmallMobile()
+const appRating = useAppRating(toRef(props, 'app'))
/**
* The HTML tag to use.
*/
const dataItemTag = computed(() => props.listView ? 'td' : 'div')
-/**
- * If the rating should be shown.
- * This is true if at least five rating were sent.
- */
-const hasRating = computed(() => props.app.appstoreData?.ratingNumOverall > 5)
-
-/**
- * Is the sidebar shown.
- * The sidebar is always shown if an app is selected.
- */
-const withSidebar = computed(() => Boolean(route.params.id))
-
/**
* Is this app is the currently selected app
*/
-const isSelected = computed(() => route.params.id === props.app.id)
+const isSelected = computed(() => route.params.appId === props.app.id)
/**
* Set table header association to a table cell.
</script>
<style scoped lang="scss">
-@use '../../../../../../core/css/variables.scss' as variables;
-@use 'sass:math';
-
.app-item {
+ box-sizing: border-box;
position: relative;
&:hover {
}
&--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);
}
padding: var(--app-item-padding);
height: var(--app-item-height);
}
-
- /* hide app version and level on narrower screens */
- @media only screen and (max-width: 900px) {
- .app-version,
- .app-level {
- display: none;
- }
- }
}
&--store-view {
- padding: 30px;
-
- @media only screen and (min-width: 1601px) {
- width: 25%;
+ border-radius: var(--border-radius-container);
+ padding: calc(3 * var(--default-grid-baseline));
+ display: flex;
+ flex-direction: column;
- &.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-item--selected {
+ outline: 2px solid var(--color-main-text);
}
}
}
-.app-version {
+.app-item__version {
color: var(--color-text-maxcontrast);
}
</style>
--- /dev/null
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes'
+import { t } from '@nextcloud/l10n'
+import { ref, toRef } from 'vue'
+import { useAppManagement } from '../../../composables/useAppManagement'
+import StatefulButton from './StatefulButton.vue'
+import { useAppState } from '../../../composables/useAppState'
+
+const props = defineProps<{
+ app: IAppStoreApp
+ dataItemTag: 'td'|'div'
+ listView?: boolean
+}>()
+
+const {
+ canInstall,
+ canUninstall,
+} = useAppState(toRef(props, 'app'))
+
+const {
+ disableApp,
+ enableApp,
+ forceEnable,
+ updateApp,
+ uninstallApp,
+} = useAppManagement()
+
+const isLoading = ref(false)
+</script>
+
+<template>
+ <component :is="dataItemTag"
+ class="app-item-actions"
+ :class="{ 'app-item-actions--grid': !listView }">
+ <div class="app-item-actions__wrapper">
+ <StatefulButton v-if="app.update"
+ :disabled="isLoading"
+ type="primary"
+ @click="updateApp(app)">
+ {{ t('settings', 'Update to {update}', { update: app.update }) }}
+ </StatefulButton>
+ <StatefulButton v-if="canUninstall"
+ class="app-item-actions__uninstall"
+ :disabled="isLoading"
+ type="tertiary"
+ @click="uninstallApp(app)">
+ {{ t('settings', 'Uninstall') }}
+ </StatefulButton>
+ <StatefulButton v-if="app.active"
+ :disabled="isLoading"
+ @click="disableApp(app)">
+ {{ t('settings', 'Disable') }}
+ </StatefulButton>
+ <StatefulButton v-if="!app.active && canInstall"
+ :title="app.needsDownload && !isLoading ? t('settings', 'The app will be downloaded from the App Store') : undefined"
+ :type="listView ? 'secondary' : 'primary'"
+ :disabled="isLoading"
+ @click="enableApp(app)">
+ {{ app.needsDownload ? t('settings', 'Download and enable') : t('settings', 'Enable') }}
+ </StatefulButton>
+ <StatefulButton v-else-if="!app.active && !app.isCompatible"
+ type="tertiary"
+ :disabled="isLoading"
+ @click="forceEnable(app)">
+ {{ t('settings', 'Allow untested app') }}
+ </StatefulButton>
+ </div>
+ </component>
+</template>
+
+<style scoped lang="scss">
+.app-item-actions {
+ &--grid {
+ align-self: end;
+ margin-block-start: auto;
+
+ .app-item-actions__wrapper {
+ flex-wrap: wrap;
+ }
+ }
+
+ &__wrapper {
+ display: flex;
+ flex-direction: row;
+ gap: var(--default-grid-baseline);
+ justify-content: end;
+ width: 100%;
+ }
+
+ &__uninstall {
+ color: var(--color-error-text);
+ }
+}
+</style>
-->
<script setup lang="ts">
-import type { IAppstoreApp } from '../../../app-types'
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes.ts'
import { computed, ref, watchEffect } from 'vue'
import IconSettings from 'vue-material-design-icons/Cog.vue'
import { preloadImage } from '../../../service/imagePreloading.ts'
+import logger from '../../../logger.ts';
const props = defineProps<{
- app: IAppstoreApp
- listView: boolean
+ app: IAppStoreApp
+ listView?: boolean
}>()
/**
if (hasPreview.value) {
preloadImage(previewUrl.value!)
.then(() => { previewLoaded.value = true })
+ .catch((error) => logger.debug(`Failed to load screenshot for ${props.app.name}`, { error }))
}
})
<style scoped lang="scss">
.app-item-icon {
- height: auto;
+ --app-icon-size: 20px;
+
+ height: var(--app-icon-size);
width: var(--default-clickable-area);
position: relative;
overflow: hidden;
}
.app-item-icon__image {
- height: 20px;
- width: 20px;
+ height: var(--app-icon-size);
+ width: var(--app-icon-size);
&--is-icon {
// if an icon is shown we need to adjust the color if needed
}
&--grid {
- height: 150px;
+ --app-icon-size: 150px;
width: auto;
.app-item-icon__image,
.app-item-icon__fallback {
- height: 150px;
+ height: var(--app-icon-size);
width: 100%;
object-fit: cover;
}
-->
<script setup lang="ts">
-import type { IAppstoreApp } from '../../../app-types'
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes'
+
import { computed } from 'vue'
+import { useRoute } from 'vue-router/composables'
const props = defineProps<{
- app: IAppstoreApp
+ app: IAppStoreApp
category: string
listView: boolean
}>()
+const route = useRoute()
+
/**
* The HTML tag to use - depending on the list vs grid view
*/
const tag = computed(() => props.listView ? 'td' : 'div')
+const to = computed(() => route.name === 'discover'
+ ? { name: 'discover', params: { appId: props.app.id } }
+ : { name: 'app-details', params: { category: props.category, appId: props.app.id } },
+)
</script>
<template>
<component :is="tag" class="app-item-name">
- <router-link class="app-item-name__link"
- :to="{
- name: 'apps-details',
- params: {
- category: category,
- id: app.id
- },
- }">
+ <router-link class="app-item-name__link" :to="to">
{{ app.name }}
</router-link>
</component>
<style scoped lang="scss">
.app-item-name {
+ font-weight: bold;
margin: calc(2 * var(--default-grid-baseline)) 0;
&__link::after {
// The list view
&--list-view {
+ font-weight: normal;
margin: 0;
padding: 0 var(--app-item-padding);
--- /dev/null
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+/**
+ * This component provides a simple wrapper over the NcButton to allow accessible disabled state.
+ * @module StatefulButton
+ */
+import { t } from '@nextcloud/l10n'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+
+const props = defineProps<{
+ disabled: boolean
+}>()
+
+const emit = defineEmits(['click'])
+
+/**
+ * Only forward the click event if not disabled
+ * @param args potential event parameters
+ */
+function onClick(...args) {
+ if (!props.disabled) {
+ emit('click', ...args)
+ }
+}
+</script>
+
+<template>
+ <NcButton :aria-disabled="disabled ? 'true' : undefined"
+ :class="{ 'soft-disabled': disabled }"
+ v-bind="$attrs"
+ @click.stop="onClick">
+ <slot />
+ <span v-if="disabled" class="hidden-visually">
+ {{ t('settings', '(app is loading)') }}
+ </span>
+ </NcButton>
+</template>
+
+<style scoped>
+.soft-disabled {
+ cursor: not-allowed;
+ opacity: .7;
+}
+</style>