mirror of
https://github.com/nextcloud/server.git
synced 2026-05-22 01:55:56 -04:00
refactor(appstore): migrate sidebar to Vue 3 and Typescript
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
3b1ed578c5
commit
5e7f45ace6
36 changed files with 1401 additions and 1257 deletions
|
|
@ -477,9 +477,8 @@ class ApiController extends OCSController {
|
|||
'missingMaxNextcloudVersion' => false,
|
||||
'missingMinNextcloudVersion' => false,
|
||||
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
|
||||
'score' => $app['ratingOverall'],
|
||||
'ratingOverall' => $app['ratingOverall'],
|
||||
'ratingNumOverall' => $app['ratingNumOverall'],
|
||||
'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
|
||||
'removable' => $existsLocally,
|
||||
'active' => $this->appManager->isEnabledForUser($app['id']),
|
||||
'needsDownload' => !$existsLocally,
|
||||
|
|
|
|||
24
apps/appstore/src/actions/actionDisable.ts
Normal file
24
apps/appstore/src/actions/actionDisable.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiClose } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canDisable } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionDisable: AppAction = {
|
||||
id: 'disable',
|
||||
icon: mdiClose,
|
||||
order: 10,
|
||||
enabled: canDisable,
|
||||
label: () => t('appstore', 'Disable'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.disableApp(app.id)
|
||||
},
|
||||
}
|
||||
27
apps/appstore/src/actions/actionEnable.ts
Normal file
27
apps/appstore/src/actions/actionEnable.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiCheck } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canEnable, canInstall } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionEnable: AppAction = {
|
||||
id: 'enable',
|
||||
icon: mdiCheck,
|
||||
order: 1,
|
||||
variant: 'primary',
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !canInstall(app) && canEnable(app)
|
||||
},
|
||||
label: () => t('appstore', 'Enable'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id)
|
||||
},
|
||||
}
|
||||
28
apps/appstore/src/actions/actionForceEnable.ts
Normal file
28
apps/appstore/src/actions/actionForceEnable.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiAlertCircleCheckOutline } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canForceEnable, canInstall, needForceEnable } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionForceEnable: AppAction = {
|
||||
id: 'force-enable',
|
||||
icon: mdiAlertCircleCheckOutline,
|
||||
order: 3,
|
||||
inline: false,
|
||||
variant: 'warning',
|
||||
label: () => t('appstore', 'Force enable'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !canInstall(app) && canForceEnable(app) && needForceEnable(app)
|
||||
},
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.forceEnableApp(app.id)
|
||||
},
|
||||
}
|
||||
34
apps/appstore/src/actions/actionInstall.ts
Normal file
34
apps/appstore/src/actions/actionInstall.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiDownload } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canInstall, needForceEnable } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionInstall: AppAction = {
|
||||
id: 'install',
|
||||
icon: mdiDownload,
|
||||
order: 5,
|
||||
enabled(app) {
|
||||
return canInstall(app) && !needForceEnable(app)
|
||||
},
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => {
|
||||
if (app.app_api) {
|
||||
return t('appstore', 'Deploy and enable')
|
||||
}
|
||||
if (app.needsDownload) {
|
||||
return t('appstore', 'Download and enable')
|
||||
}
|
||||
return t('appstore', 'Install and enable')
|
||||
},
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id)
|
||||
},
|
||||
}
|
||||
35
apps/appstore/src/actions/actionInstallForced.ts
Normal file
35
apps/appstore/src/actions/actionInstallForced.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiDownload } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canInstall, needForceEnable } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionInstallForced: AppAction = {
|
||||
id: 'install-forced',
|
||||
icon: mdiDownload,
|
||||
order: 5,
|
||||
inline: false,
|
||||
enabled(app) {
|
||||
return canInstall(app) && needForceEnable(app)
|
||||
},
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => {
|
||||
if (app.app_api) {
|
||||
return t('appstore', 'Deploy and force enable')
|
||||
}
|
||||
if (app.needsDownload) {
|
||||
return t('appstore', 'Download and force enable')
|
||||
}
|
||||
return t('appstore', 'Install and force enable')
|
||||
},
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id, true)
|
||||
},
|
||||
}
|
||||
65
apps/appstore/src/actions/actionInteract.ts
Normal file
65
apps/appstore/src/actions/actionInteract.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiBugOutline, mdiForumOutline, mdiStarOutline, mdiWeb } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export const actionsInteract: AppAction[] = [
|
||||
{
|
||||
id: 'rate',
|
||||
icon: mdiStarOutline,
|
||||
order: 30,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Rate the app'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !!app.fromAppStore
|
||||
},
|
||||
href(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return `https://apps.nextcloud.com/apps/${encodeURIComponent(app.id)}#comments`
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'report-bug',
|
||||
icon: mdiBugOutline,
|
||||
order: 32,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Report a bug'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !!app.bugs
|
||||
},
|
||||
href(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.bugs!
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'discussion',
|
||||
icon: mdiForumOutline,
|
||||
order: 35,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Ask questions or discuss the app'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !!app.discussion
|
||||
},
|
||||
href(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.discussion!
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'website',
|
||||
icon: mdiWeb,
|
||||
order: 38,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Visit the website'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !!app.website
|
||||
},
|
||||
href(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.website!
|
||||
},
|
||||
},
|
||||
]
|
||||
27
apps/appstore/src/actions/actionLimitToGroup.ts
Normal file
27
apps/appstore/src/actions/actionLimitToGroup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiAccountGroup } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { spawnDialog } from '@nextcloud/vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { canLimitToGroups } from '../utils/appStatus.ts'
|
||||
|
||||
const LimitToGroupDialog = defineAsyncComponent(() => import('../components/LimitToGroupDialog.vue'))
|
||||
|
||||
export const actionLimitToGroup: AppAction = {
|
||||
id: 'limit-to-group',
|
||||
icon: mdiAccountGroup,
|
||||
order: 16,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Limit to groups'),
|
||||
enabled: canLimitToGroups,
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
await spawnDialog(LimitToGroupDialog, { app })
|
||||
},
|
||||
}
|
||||
26
apps/appstore/src/actions/actionRemove.ts
Normal file
26
apps/appstore/src/actions/actionRemove.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiTrashCanOutline } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canUninstall } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionRemove: AppAction = {
|
||||
id: 'remove',
|
||||
order: 20,
|
||||
icon: mdiTrashCanOutline,
|
||||
variant: 'error',
|
||||
inline: false,
|
||||
enabled: canUninstall,
|
||||
label: () => t('appstore', 'Remove'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.uninstallApp(app.id)
|
||||
},
|
||||
}
|
||||
38
apps/appstore/src/actions/actionUpdate.ts
Normal file
38
apps/appstore/src/actions/actionUpdate.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiUpdate } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useExAppsStore } from '../store/exApps.ts'
|
||||
import { useUpdatesStore } from '../store/updates.ts'
|
||||
import { canUpdate } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionUpdate: AppAction = {
|
||||
id: 'update',
|
||||
icon: mdiUpdate,
|
||||
variant: 'primary',
|
||||
order: 0,
|
||||
enabled(app) {
|
||||
if (!canUpdate(app)) {
|
||||
return false
|
||||
}
|
||||
if (app.app_api) {
|
||||
if (app.daemon && app.daemon?.accepts_deploy_id === 'manual-install') {
|
||||
return true
|
||||
}
|
||||
const exAppsStore = useExAppsStore()
|
||||
return exAppsStore.daemonAccessible
|
||||
}
|
||||
return true
|
||||
},
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => t('appstore', 'Update to {version}', { version: app.update! }),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useUpdatesStore()
|
||||
await store.updateApp(app.id)
|
||||
},
|
||||
}
|
||||
54
apps/appstore/src/actions/index.ts
Normal file
54
apps/appstore/src/actions/index.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
*/
|
||||
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { actionDisable } from './actionDisable.ts'
|
||||
import { actionEnable } from './actionEnable.ts'
|
||||
import { actionForceEnable } from './actionForceEnable.ts'
|
||||
import { actionInstall } from './actionInstall.ts'
|
||||
import { actionInstallForced } from './actionInstallForced.ts'
|
||||
import { actionsInteract } from './actionInteract.ts'
|
||||
import { actionLimitToGroup } from './actionLimitToGroup.ts'
|
||||
import { actionRemove } from './actionRemove.ts'
|
||||
import { actionUpdate } from './actionUpdate.ts'
|
||||
|
||||
interface AppActionBase {
|
||||
enabled: (app: IAppstoreApp | IAppstoreExApp) => boolean
|
||||
|
||||
id: string
|
||||
icon: string
|
||||
order: number
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => string
|
||||
variant?: 'primary' | 'error' | 'warning'
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
interface AppActionWithCallback extends AppActionBase {
|
||||
callback: (app: IAppstoreApp | IAppstoreExApp) => Promise<void>
|
||||
}
|
||||
|
||||
interface AppActionWithHref extends AppActionBase {
|
||||
href: (app: IAppstoreApp | IAppstoreExApp) => string
|
||||
}
|
||||
|
||||
interface AppActionWithRoute extends AppActionBase {
|
||||
to: (app: IAppstoreApp | IAppstoreExApp) => RouteLocationRaw
|
||||
}
|
||||
|
||||
export type AppAction = AppActionWithCallback | AppActionWithHref | AppActionWithRoute
|
||||
|
||||
export const actions = [
|
||||
actionUpdate,
|
||||
actionEnable,
|
||||
actionDisable,
|
||||
actionForceEnable,
|
||||
actionInstall,
|
||||
actionInstallForced,
|
||||
actionRemove,
|
||||
actionLimitToGroup,
|
||||
...actionsInteract,
|
||||
].sort((a, b) => a.order - b.order)
|
||||
84
apps/appstore/src/apps.d.ts
vendored
84
apps/appstore/src/apps.d.ts
vendored
|
|
@ -20,6 +20,7 @@ export interface IAppstoreCategory {
|
|||
|
||||
export interface IAppstoreAppRelease {
|
||||
version: string
|
||||
lastModified?: string
|
||||
translations: {
|
||||
[key: string]: {
|
||||
changelog: string
|
||||
|
|
@ -27,50 +28,89 @@ export interface IAppstoreAppRelease {
|
|||
}
|
||||
}
|
||||
|
||||
export interface IAppstoreAppData extends Record<string, unknown> {
|
||||
ratingOverall: number
|
||||
ratingNumOverall: number
|
||||
ratingRecent: number
|
||||
ratingNumRecent: number
|
||||
type IAppInfoTypes = 'prelogin' | 'filesystem' | 'authentication' | 'extended_authentication' | 'logging' | 'dav' | 'prevent_group_restriction' | 'session'
|
||||
|
||||
releases: IAppstoreAppRelease[]
|
||||
}
|
||||
|
||||
export interface IAppstoreAppResponse {
|
||||
/**
|
||||
* The metadata that is available in the info.xml of an app.
|
||||
* This is sourced by the appstore but also available for already installed apps (e.g. shipped apps).
|
||||
*/
|
||||
interface IAppInfoData {
|
||||
id: string
|
||||
name: string
|
||||
summary: string
|
||||
description: string
|
||||
/** The license of the app */
|
||||
license: string
|
||||
/** The author(s) of the app (either list of names or object for XML nodes) */
|
||||
author: string[] | Record<string, string>
|
||||
/** The support level of this app (e.g. maintained by Nextcloud GmbH) */
|
||||
level: number
|
||||
/** The version of the app */
|
||||
version: string
|
||||
/** The category(s) this app belongs to */
|
||||
category: string | string[]
|
||||
|
||||
icon?: string
|
||||
/** The URL of the app's screenshot */
|
||||
screenshot?: string
|
||||
/** The types this app supports */
|
||||
types?: IAppInfoTypes[]
|
||||
|
||||
/**
|
||||
* Groups this app is limited to.
|
||||
* (only available if app is already installed)
|
||||
*/
|
||||
groups?: string[]
|
||||
documentation?: {
|
||||
admin: string
|
||||
user: string
|
||||
developer: string
|
||||
}
|
||||
website?: string
|
||||
discussion?: string
|
||||
bugs?: string
|
||||
}
|
||||
|
||||
score: number
|
||||
ratingNumThresholdReached: boolean
|
||||
/**
|
||||
* Metadata added when this app is sourced from the appstore.
|
||||
* It is not available for non-appstore apps.
|
||||
*/
|
||||
interface IAppstoreMetadata {
|
||||
fromAppStore: true
|
||||
/** List of appstore release information (e.g. changelog) */
|
||||
releases: IAppstoreAppRelease[]
|
||||
/** The overall rating of the app */
|
||||
ratingOverall: number
|
||||
/** The number of ratings for the app */
|
||||
ratingNumOverall: number
|
||||
}
|
||||
|
||||
export interface IAppstoreAppResponse extends IAppInfoData, Partial<IAppstoreMetadata> {
|
||||
/** The app icon to use */
|
||||
icon?: string
|
||||
|
||||
// App dependency information
|
||||
dependencies: unknown
|
||||
missingMaxNextcloudVersion: boolean
|
||||
missingMinNextcloudVersion: boolean
|
||||
|
||||
// App state information
|
||||
|
||||
/** Whether the app is an ExApp (docker based app) */
|
||||
app_api: false
|
||||
active: boolean
|
||||
/** Whether the app is internal = always enabled an cannot be disabled */
|
||||
internal: boolean
|
||||
/** Whether the app is shipped / bundled with Nextcloud (not from appstore) */
|
||||
shipped: boolean
|
||||
/** Whether the app is currently active (enabled) */
|
||||
active: boolean
|
||||
/** Whether the app can be removed */
|
||||
removable: boolean
|
||||
/** Whether the app is installed */
|
||||
installed: boolean
|
||||
/** If all dependencies are met */
|
||||
isCompatible: boolean
|
||||
/** Whether the app needs to be downloaded (not locally available) */
|
||||
needsDownload: boolean
|
||||
/** List of missing dependencies */
|
||||
missingDependencies?: string[]
|
||||
/** Available update version */
|
||||
update?: string
|
||||
|
||||
appstoreData?: IAppstoreAppData
|
||||
releases?: IAppstoreAppRelease[]
|
||||
/** User groups this app is limited to */
|
||||
groups?: string[]
|
||||
}
|
||||
|
||||
export interface IAppstoreApp extends IAppstoreAppResponse {
|
||||
|
|
|
|||
103
apps/appstore/src/components/AppActions.vue
Normal file
103
apps/appstore/src/components/AppActions.vue
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AppAction } from '../actions/index.ts'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcActionLink from '@nextcloud/vue/components/NcActionLink'
|
||||
import NcActionRouter from '@nextcloud/vue/components/NcActionRouter'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
const { actions, maxInlineActions = 1 } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
actions: AppAction[]
|
||||
maxInlineActions?: number
|
||||
iconOnly?: boolean
|
||||
}>()
|
||||
|
||||
const inlineActions = computed(() => {
|
||||
if (actions.length <= maxInlineActions) {
|
||||
return actions
|
||||
}
|
||||
return actions
|
||||
.filter((action) => action.inline !== false)
|
||||
.slice(0, maxInlineActions)
|
||||
})
|
||||
|
||||
const menuActions = computed(() => actions
|
||||
.filter((action) => !inlineActions.value.includes(action)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.appActions">
|
||||
<NcButton
|
||||
v-for="action in inlineActions"
|
||||
:key="action.id"
|
||||
:ariaLabel="iconOnly ? action.label(app) : undefined"
|
||||
:title="iconOnly ? action.label(app) : undefined"
|
||||
:variant="action.variant"
|
||||
:href="'href' in action ? action.href(app) : undefined"
|
||||
:to="'to' in action ? action.to(app) : undefined"
|
||||
:target="'href' in action ? '_blank' : undefined"
|
||||
@click="'callback' in action && action.callback(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
<template v-if="!iconOnly" #default>
|
||||
{{ action.label(app) }}
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcActions forceMenu>
|
||||
<template v-for="action in menuActions">
|
||||
<NcActionButton
|
||||
v-if="'callback' in action"
|
||||
:key="'callback-' + action.id"
|
||||
closeAfterClick
|
||||
:variant="action.variant"
|
||||
@click="action.callback(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
{{ action.label(app) }}
|
||||
</NcActionButton>
|
||||
<NcActionLink
|
||||
v-else-if="'href' in action"
|
||||
:key="'link-' + action.id"
|
||||
closeAfterClick
|
||||
:variant="action.variant"
|
||||
:href="action.href(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
{{ action.label(app) }}
|
||||
</NcActionLink>
|
||||
<NcActionRouter
|
||||
v-else
|
||||
:key="'route-' + action.id"
|
||||
closeAfterClick
|
||||
:variant="action.variant"
|
||||
:to="action.to(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
{{ action.label(app) }}
|
||||
</NcActionRouter>
|
||||
</template>
|
||||
</NcActions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: calc(2 * var(--default-grid-baseline));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,519 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
id="details"
|
||||
:name="t('appstore', 'Details')"
|
||||
:order="1">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTextBoxOutline" />
|
||||
</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('appstore', 'Limit to groups') }}</label>
|
||||
<input
|
||||
type="hidden"
|
||||
class="group_select"
|
||||
:title="t('appstore', 'All')"
|
||||
value="">
|
||||
<br>
|
||||
<label for="limitToGroups">
|
||||
<span>{{ t('appstore', 'Limit app usage to groups') }}</span>
|
||||
</label>
|
||||
<NcSelect
|
||||
v-if="isLimitedToGroups(app)"
|
||||
input-id="limitToGroups"
|
||||
:options="groups"
|
||||
:model-value="appGroups"
|
||||
:limit="5"
|
||||
label="name"
|
||||
:multiple="true"
|
||||
keep-open
|
||||
@option:selected="addGroupLimitation"
|
||||
@option:deselected="removeGroupLimitation"
|
||||
@search="asyncFindGroup">
|
||||
<span slot="noResult">{{ t('appstore', 'No results') }}</span>
|
||||
</NcSelect>
|
||||
</div>
|
||||
<div class="app-details__actions-manage">
|
||||
<input
|
||||
v-if="app.update"
|
||||
class="update primary"
|
||||
type="button"
|
||||
:value="t('appstore', 'Update to {version}', { version: app.update })"
|
||||
:disabled="installing || isLoading || isManualInstall"
|
||||
@click="update(app.id)">
|
||||
<input
|
||||
v-if="app.canUnInstall"
|
||||
class="uninstall"
|
||||
type="button"
|
||||
:value="t('appstore', 'Remove')"
|
||||
:disabled="installing || isLoading"
|
||||
@click="remove(app.id, removeData)">
|
||||
<input
|
||||
v-if="app.active"
|
||||
class="enable"
|
||||
type="button"
|
||||
:value="disableButtonText"
|
||||
:disabled="installing || isLoading || isInitializing || isDeploying"
|
||||
@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 || !defaultDeployDaemonAccessible || isInitializing || isDeploying"
|
||||
@click="enableButtonAction">
|
||||
<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)">
|
||||
<NcButton
|
||||
v-if="app?.app_api && (app.canInstall || app.isCompatible)"
|
||||
:aria-label="t('appstore', 'Advanced deploy options')"
|
||||
variant="secondary"
|
||||
@click="() => showDeployOptionsModal = true">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiToyBrickPlusOutline" />
|
||||
</template>
|
||||
{{ t('appstore', 'Deploy options') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<p v-if="!defaultDeployDaemonAccessible" class="warning">
|
||||
{{ t('appstore', 'Default Deploy daemon is not accessible') }}
|
||||
</p>
|
||||
<NcCheckboxRadioSwitch
|
||||
v-if="app.canUnInstall"
|
||||
:model-value="removeData"
|
||||
:disabled="installing || isLoading || !defaultDeployDaemonAccessible"
|
||||
@update:modelValue="toggleRemoveData">
|
||||
{{ t('appstore', 'Delete data on remove') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<ul class="app-details__dependencies">
|
||||
<li v-if="app.missingMinOwnCloudVersion">
|
||||
{{ t('appstore', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
</li>
|
||||
<li v-if="app.missingMaxOwnCloudVersion">
|
||||
{{ t('appstore', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
</li>
|
||||
<li v-if="!app.canInstall">
|
||||
{{ t('appstore', '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">
|
||||
<h4>
|
||||
{{ t('appstore', 'Latest updated') }}
|
||||
</h4>
|
||||
<NcDateTime :timestamp="lastModified" />
|
||||
</div>
|
||||
|
||||
<div class="app-details__section">
|
||||
<h4>
|
||||
{{ t('appstore', 'Author') }}
|
||||
</h4>
|
||||
<p class="app-details__authors">
|
||||
{{ appAuthors }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="app-details__section">
|
||||
<h4>
|
||||
{{ t('appstore', 'Categories') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ appCategories }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="externalResources.length > 0" class="app-details__section">
|
||||
<h4>{{ t('appstore', 'Resources') }}</h4>
|
||||
<ul class="app-details__documentation" :aria-label="t('appstore', '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('appstore', 'Interact') }}</h4>
|
||||
<div class="app-details__interact">
|
||||
<NcButton
|
||||
:disabled="!app.bugs"
|
||||
:href="app.bugs ?? '#'"
|
||||
:aria-label="t('appstore', 'Report a bug')"
|
||||
:title="t('appstore', 'Report a bug')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiBugOutline" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:disabled="!app.bugs"
|
||||
:href="app.bugs ?? '#'"
|
||||
:aria-label="t('appstore', 'Request feature')"
|
||||
:title="t('appstore', 'Request feature')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFeatureSearchOutline" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="app.appstoreData?.discussion"
|
||||
:href="app.appstoreData.discussion"
|
||||
:aria-label="t('appstore', 'Ask questions or discuss')"
|
||||
:title="t('appstore', 'Ask questions or discuss')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTooltipQuestionOutline" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="!app.internal"
|
||||
:href="rateAppUrl"
|
||||
:aria-label="t('appstore', 'Rate the app')"
|
||||
:title="t('appstore', 'Rate')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiStar" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppDeployOptionsModal
|
||||
v-if="app?.app_api"
|
||||
:show.sync="showDeployOptionsModal"
|
||||
:app="app" />
|
||||
<DaemonSelectionDialog
|
||||
v-if="app?.app_api"
|
||||
:show.sync="showSelectDaemonModal"
|
||||
:app="app"
|
||||
:deploy-options="deployOptions" />
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mdiBugOutline, mdiFeatureSearchOutline, mdiStar, mdiTextBoxOutline, mdiTooltipQuestionOutline, mdiToyBrickPlusOutline } from '@mdi/js'
|
||||
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue'
|
||||
import AppDeployOptionsModal from './AppDeployOptionsModal.vue'
|
||||
import AppManagement from '../../mixins/AppManagement.js'
|
||||
import { useAppApiStore } from '../../store/app-api-store.js'
|
||||
import { useAppsStore } from '../../store/apps-store.js'
|
||||
|
||||
export default {
|
||||
name: 'AppDetailsTab',
|
||||
|
||||
components: {
|
||||
NcAppSidebarTab,
|
||||
NcButton,
|
||||
NcDateTime,
|
||||
NcIconSvgWrapper,
|
||||
NcSelect,
|
||||
NcCheckboxRadioSwitch,
|
||||
AppDeployOptionsModal,
|
||||
DaemonSelectionDialog,
|
||||
},
|
||||
|
||||
mixins: [AppManagement],
|
||||
|
||||
props: {
|
||||
app: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const store = useAppsStore()
|
||||
const appApiStore = useAppApiStore()
|
||||
|
||||
return {
|
||||
store,
|
||||
appApiStore,
|
||||
|
||||
productName: window.OC.theme.productName,
|
||||
|
||||
mdiBugOutline,
|
||||
mdiFeatureSearchOutline,
|
||||
mdiStar,
|
||||
mdiTextBoxOutline,
|
||||
mdiTooltipQuestionOutline,
|
||||
mdiToyBrickPlusOutline,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
groupCheckedAppsData: false,
|
||||
removeData: false,
|
||||
showDeployOptionsModal: false,
|
||||
showSelectDaemonModal: false,
|
||||
deployOptions: null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
lastModified() {
|
||||
return (this.app.appstoreData?.releases ?? [])
|
||||
.map(({ lastModified }) => Date.parse(lastModified))
|
||||
.sort()
|
||||
.at(0) ?? null
|
||||
},
|
||||
|
||||
/**
|
||||
* App authors as comma separated string
|
||||
*/
|
||||
appAuthors() {
|
||||
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('appstore', 'View in store'),
|
||||
})
|
||||
}
|
||||
if (this.app.website) {
|
||||
resources.push({
|
||||
id: 'website',
|
||||
href: this.app.website,
|
||||
label: t('appstore', 'Visit website'),
|
||||
})
|
||||
}
|
||||
if (this.app.documentation) {
|
||||
if (this.app.documentation.user) {
|
||||
resources.push({
|
||||
id: 'doc-user',
|
||||
href: this.app.documentation.user,
|
||||
label: t('appstore', 'Usage documentation'),
|
||||
})
|
||||
}
|
||||
if (this.app.documentation.admin) {
|
||||
resources.push({
|
||||
id: 'doc-admin',
|
||||
href: this.app.documentation.admin,
|
||||
label: t('appstore', 'Admin documentation'),
|
||||
})
|
||||
}
|
||||
if (this.app.documentation.developer) {
|
||||
resources.push({
|
||||
id: 'doc-developer',
|
||||
href: this.app.documentation.developer,
|
||||
label: t('appstore', '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))
|
||||
},
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
this.deployOptions = null
|
||||
unsubscribe('showDaemonSelectionModal')
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.app.groups.length > 0) {
|
||||
this.groupCheckedAppsData = true
|
||||
}
|
||||
subscribe('showDaemonSelectionModal', (deployOptions) => {
|
||||
this.showSelectionModal(deployOptions)
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleRemoveData() {
|
||||
this.removeData = !this.removeData
|
||||
},
|
||||
|
||||
showSelectionModal(deployOptions = null) {
|
||||
this.deployOptions = deployOptions
|
||||
this.showSelectDaemonModal = true
|
||||
},
|
||||
|
||||
async enableButtonAction() {
|
||||
if (!this.app?.app_api) {
|
||||
this.enable(this.app.id)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-details {
|
||||
padding: 20px;
|
||||
|
||||
&__actions {
|
||||
// app management
|
||||
&-manage {
|
||||
// if too many, shrink them and ellipsis
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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-text-error);
|
||||
border-color: var(--color-border-error);
|
||||
background: var(--color-main-background);
|
||||
}
|
||||
|
||||
.force:hover,
|
||||
.force:active {
|
||||
color: var(--color-main-background);
|
||||
border-color: var(--color-border-error) !important;
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.missing-dependencies {
|
||||
list-style: initial;
|
||||
list-style-type: initial;
|
||||
list-style-position: inside;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
v-if="hasChangelog"
|
||||
id="changelog"
|
||||
:name="t('appstore', 'Changelog')"
|
||||
: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>
|
||||
<MarkdownPreview
|
||||
class="app-sidebar-tabs__release-text"
|
||||
:text="createChangelogFromRelease(release)" />
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import MarkdownPreview from '../MarkdownPreview.vue'
|
||||
|
||||
const props = defineProps<{ app: IAppstoreApp }>()
|
||||
|
||||
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 ?? ''
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-sidebar-tabs__release {
|
||||
h2 {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
// Overwrite changelog heading styles
|
||||
:deep(h3) {
|
||||
font-size: 20px;
|
||||
}
|
||||
:deep(h4) {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,17 +4,19 @@
|
|||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AppAction } from '../../actions/index.ts'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiInformationOutline } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppActions from '../AppActions.vue'
|
||||
import AppDaemonBadge from '../AppDaemonBadge.vue'
|
||||
import AppIcon from '../AppIcon.vue'
|
||||
import AppLevelBadge from '../AppLevelBadge.vue'
|
||||
import AppActions from '../AppActions.vue'
|
||||
import { useActions } from '../../composables/useActions.ts'
|
||||
|
||||
const { app, isNarrow } = defineProps<{
|
||||
|
|
@ -22,15 +24,30 @@ const { app, isNarrow } = defineProps<{
|
|||
isNarrow?: boolean
|
||||
}>()
|
||||
|
||||
const actions = useActions(() => app)
|
||||
const route = useRoute()
|
||||
const detailsRoute = computed(() => ({
|
||||
name: route.name!,
|
||||
...route,
|
||||
params: {
|
||||
...route.params,
|
||||
id: app.id,
|
||||
},
|
||||
}))
|
||||
|
||||
const detailsAction = computed<AppAction>(() => ({
|
||||
id: 'details',
|
||||
order: 99,
|
||||
enabled: () => true,
|
||||
label: () => t('appstore', 'Show details'),
|
||||
icon: mdiInformationOutline,
|
||||
to: () => detailsRoute.value,
|
||||
inline: false,
|
||||
}))
|
||||
|
||||
const rawActions = useActions(() => app)
|
||||
const actions = computed(() => [
|
||||
...rawActions.value,
|
||||
detailsAction.value,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -3,28 +3,8 @@
|
|||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
v-if="app?.daemon"
|
||||
id="daemon"
|
||||
:name="t('appstore', 'Daemon')"
|
||||
:order="3">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFileChart" :size="24" />
|
||||
</template>
|
||||
<div class="daemon">
|
||||
<h4>{{ t('appstore', 'Deploy Daemon') }}</h4>
|
||||
<p><b>{{ t('appstore', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
|
||||
<p><b>{{ t('appstore', 'Name') }}</b>: {{ app?.daemon.name }}</p>
|
||||
<p><b>{{ t('appstore', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
|
||||
<p><b>{{ t('appstore', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
|
||||
<p><b>{{ t('appstore', 'Compute device') }}</b>: {{ app?.daemon?.deploy_config?.computeDevice?.label }}</p>
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreExApp } from '../../app-types.ts'
|
||||
import type { IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiFileChart } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
|
@ -39,13 +19,33 @@ const props = defineProps<{
|
|||
const gpuSupport = ref(props.app?.daemon?.deploy_config?.computeDevice?.id !== 'cpu' || false)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.daemon {
|
||||
padding: 20px;
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
v-if="app?.daemon"
|
||||
id="daemon"
|
||||
:name="t('appstore', 'Daemon')"
|
||||
:order="5">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFileChart" :size="24" />
|
||||
</template>
|
||||
<div :class="$style.appDeployDaemonTab">
|
||||
<h4>{{ t('appstore', 'Deploy Daemon') }}</h4>
|
||||
<p><b>{{ t('appstore', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
|
||||
<p><b>{{ t('appstore', 'Name') }}</b>: {{ app?.daemon.name }}</p>
|
||||
<p><b>{{ t('appstore', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
|
||||
<p><b>{{ t('appstore', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
|
||||
<p><b>{{ t('appstore', 'Compute device') }}</b>: {{ app?.daemon?.deploy_config?.computeDevice?.label }}</p>
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
margin: 10px auto;
|
||||
}
|
||||
<style module>
|
||||
.appDeployDaemonTab {
|
||||
padding: 20px;
|
||||
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,6 +3,20 @@
|
|||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiTextShort } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import MarkdownPreview from '../MarkdownPreview.vue'
|
||||
|
||||
defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
id="desc"
|
||||
|
|
@ -11,28 +25,14 @@
|
|||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTextShort" />
|
||||
</template>
|
||||
<div class="app-description">
|
||||
<Markdown :text="app.description" :min-heading="4" />
|
||||
<div :class="$style.appDescriptionTab">
|
||||
<MarkdownPreview :text="app.description" :minHeadingLevel="3" />
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp } from '../../app-types.ts'
|
||||
|
||||
import { mdiTextShort } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import MarkdownPreview from '../MarkdownPreview.vue'
|
||||
|
||||
defineProps<{
|
||||
app: IAppstoreApp
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-description {
|
||||
<style module>
|
||||
.appDescriptionTab {
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
267
apps/appstore/src/components/AppstoreSidebar/AppDetailsTab.vue
Normal file
267
apps/appstore/src/components/AppstoreSidebar/AppDetailsTab.vue
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiTextBoxOutline } from '@mdi/js'
|
||||
import { getCapabilities } from '@nextcloud/capabilities'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, useId } from 'vue'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import AppDaemonBadge from '../AppDaemonBadge.vue'
|
||||
import AppLevelBadge from '../AppLevelBadge.vue'
|
||||
import AppScore from '../AppScore.vue'
|
||||
import { useAppsStore } from '../../store/apps.ts'
|
||||
|
||||
const { app } = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>()
|
||||
|
||||
const store = useAppsStore()
|
||||
|
||||
// @ts-expect-error - missing types
|
||||
const productName = getCapabilities().theming.productName as string
|
||||
const idLimitedToGroups = useId()
|
||||
|
||||
const lastModified = computed(() => app.releases
|
||||
?.map((release) => release.lastModified)
|
||||
.map((date) => Date.parse(date))
|
||||
.sort()
|
||||
.at(-1))
|
||||
|
||||
/**
|
||||
* App authors as comma separated string
|
||||
*/
|
||||
const appAuthors = computed(() => {
|
||||
if (!app) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return [app.author].flat().map(authorName)
|
||||
.sort((a, b) => a.split(' ').at(-1)!.localeCompare(b.split(' ').at(-1)!))
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
const groupsAppIsLimitedto = computed(() => {
|
||||
if (!app.groups) {
|
||||
return []
|
||||
}
|
||||
|
||||
return app.groups.map((group) => ({ id: group, name: group }))
|
||||
})
|
||||
|
||||
const appstoreUrl = computed(() => `https://apps.nextcloud.com/apps/${app.id}`)
|
||||
|
||||
/**
|
||||
* Further external resources (e.g. website)
|
||||
*/
|
||||
const externalResources = computed(() => {
|
||||
const resources: { id: string, href: string, label: string }[] = []
|
||||
if (!app.internal) {
|
||||
resources.push({
|
||||
id: 'appstore',
|
||||
href: appstoreUrl.value,
|
||||
label: t('appstore', 'View in store'),
|
||||
})
|
||||
}
|
||||
if (app.website) {
|
||||
resources.push({
|
||||
id: 'website',
|
||||
href: app.website,
|
||||
label: t('appstore', 'Visit website'),
|
||||
})
|
||||
}
|
||||
if (app.documentation) {
|
||||
if (app.documentation.user) {
|
||||
resources.push({
|
||||
id: 'doc-user',
|
||||
href: app.documentation.user,
|
||||
label: t('appstore', 'Usage documentation'),
|
||||
})
|
||||
}
|
||||
if (app.documentation.admin) {
|
||||
resources.push({
|
||||
id: 'doc-admin',
|
||||
href: app.documentation.admin,
|
||||
label: t('appstore', 'Admin documentation'),
|
||||
})
|
||||
}
|
||||
if (app.documentation.developer) {
|
||||
resources.push({
|
||||
id: 'doc-developer',
|
||||
href: app.documentation.developer,
|
||||
label: t('appstore', 'Developer documentation'),
|
||||
})
|
||||
}
|
||||
}
|
||||
return resources
|
||||
})
|
||||
|
||||
const appCategories = computed(() => {
|
||||
return [app.category].flat()
|
||||
.map((id) => store.getCategoryById(id)?.displayName ?? id)
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the author name from the XML node
|
||||
*
|
||||
* @param xmlNode - The XML node to get the author name from
|
||||
*/
|
||||
function authorName(xmlNode): string {
|
||||
if (xmlNode['@value']) {
|
||||
// Complex node (with email or homepage attribute)
|
||||
return xmlNode['@value']
|
||||
}
|
||||
// Simple text node
|
||||
return xmlNode
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
id="details"
|
||||
:name="t('appstore', 'Details')"
|
||||
:order="1">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTextBoxOutline" />
|
||||
</template>
|
||||
<div class="app-details">
|
||||
<!-- Featured/Supported badges -->
|
||||
<div :class="$style.appstoreDetailsTab__badges">
|
||||
<AppLevelBadge :level="app.level" />
|
||||
<AppDaemonBadge v-if="app.app_api && app.daemon" :daemon="app.daemon" />
|
||||
<AppScore v-if="(app.ratingNumOverall ?? 0) > 5" :score="app.ratingOverall!" />
|
||||
</div>
|
||||
|
||||
<NcNoteCard v-if="app.missingMinNextcloudVersion || app.missingMaxNextcloudVersion" type="warning">
|
||||
<template v-if="app.missingMinNextcloudVersion">
|
||||
{{ t('appstore', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
</template>
|
||||
<template v-if="app.missingMaxNextcloudVersion">
|
||||
{{ t('appstore', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
</template>
|
||||
</NcNoteCard>
|
||||
|
||||
<NcNoteCard v-if="!app.isCompatible && app.missingDependencies && app.missingDependencies.length" type="error">
|
||||
{{ t('appstore', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
|
||||
<ul :aria-label="t('appstore', 'Missing dependencies')" :class="$style.appstoreDetailsTab__missingDependencies">
|
||||
<li v-for="(dep, index) in app.missingDependencies" :key="index">
|
||||
{{ dep }}
|
||||
</li>
|
||||
</ul>
|
||||
</NcNoteCard>
|
||||
|
||||
<div v-if="groupsAppIsLimitedto.length" :class="$style.appstoreDetailsTab__section">
|
||||
<h4 :id="idLimitedToGroups">
|
||||
{{ t('appstore', 'Limited to groups') }}
|
||||
</h4>
|
||||
<ul :aria-labelledby="idLimitedToGroups" :class="$style.appstoreDetailsTab__sectionDetails">
|
||||
<li
|
||||
v-for="group of groupsAppIsLimitedto"
|
||||
:key="group.id"
|
||||
:title="group.id">
|
||||
{{ group.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="lastModified && !app.shipped" :class="$style.appstoreDetailsTab__section">
|
||||
<h4>
|
||||
{{ t('appstore', 'Latest updated') }}
|
||||
</h4>
|
||||
<NcDateTime :class="$style.appstoreDetailsTab__sectionDetails" :timestamp="lastModified" />
|
||||
</div>
|
||||
|
||||
<div :class="$style.appstoreDetailsTab__section">
|
||||
<h4>
|
||||
{{ t('appstore', 'Author') }}
|
||||
</h4>
|
||||
<p :class="$style.appstoreDetailsTab__sectionDetails">
|
||||
{{ appAuthors }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :class="$style.appstoreDetailsTab__section">
|
||||
<h4>
|
||||
{{ t('appstore', 'Categories') }}
|
||||
</h4>
|
||||
<p :class="$style.appstoreDetailsTab__sectionDetails">
|
||||
{{ appCategories }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="externalResources.length > 0" :class="$style.appstoreDetailsTab__section">
|
||||
<h4>{{ t('appstore', 'Resources') }}</h4>
|
||||
<ul
|
||||
:class="$style.appstoreDetailsTab__resources"
|
||||
:aria-label="t('appstore', 'Documentation resources')">
|
||||
<li
|
||||
v-for="resource of externalResources"
|
||||
:key="resource.id"
|
||||
:class="$style.appstoreDetailsTab__resourcesItem">
|
||||
<a
|
||||
:class="$style.appstoreDetailsTab__resourcesLink"
|
||||
:href="resource.href"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener">
|
||||
{{ resource.label }} ↗
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreDetailsTab__badges {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__section {
|
||||
margin-top: 15px;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-block-end: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__sectionDetails {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__missingDependencies {
|
||||
list-style: disc;
|
||||
padding-block: 0.5lh 0;
|
||||
padding-inline: 1em 0;
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__resourcesLink {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__resourcesItem {
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreAppRelease, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiClockFast } from '@mdi/js'
|
||||
import { getLanguage, t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import MarkdownPreview from '../MarkdownPreview.vue'
|
||||
|
||||
const props = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>()
|
||||
|
||||
const releases = computed(() => (props.app.releases ?? [])
|
||||
.filter((release) => {
|
||||
const values = Object.values(release.translations ?? {})
|
||||
return values.length > 0 && values.some(({ changelog }) => !!changelog)
|
||||
}))
|
||||
|
||||
/**
|
||||
* Create a changelog text from a release
|
||||
*
|
||||
* @param release - The release to create the changelog from
|
||||
*/
|
||||
function createChangelogFromRelease(release: IAppstoreAppRelease) {
|
||||
const localizedEntry = release.translations[getLanguage()]
|
||||
return localizedEntry?.changelog ?? release.translations.en?.changelog ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
v-if="releases.length > 0"
|
||||
id="changelog"
|
||||
:name="t('appstore', 'Changelog')"
|
||||
:order="2">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiClockFast" :size="24" />
|
||||
</template>
|
||||
<div v-for="release in releases" :key="release.version" :class="$style.appReleasesTab">
|
||||
<h3 :class="$style.appReleasesTab__heading">
|
||||
{{ release.version }}
|
||||
</h3>
|
||||
<MarkdownPreview
|
||||
:class="$style.appReleasesTab__text"
|
||||
:minHeadingLevel="3"
|
||||
:text="createChangelogFromRelease(release)" />
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appReleasesTab__heading {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.appReleasesTab__text {
|
||||
/* Overwrite changelog heading styles */
|
||||
h4 {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
104
apps/appstore/src/components/LimitToGroupDialog.vue
Normal file
104
apps/appstore/src/components/LimitToGroupDialog.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcSelectUsers, { type NcSelectUsersModel } from '@nextcloud/vue/components/NcSelectUsers'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { useGroupsStore } from '../store/groups.ts'
|
||||
|
||||
const { app } = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const store = useAppsStore()
|
||||
const groupsStore = useGroupsStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const groups = ref<NcSelectUsersModel[]>([])
|
||||
watch(() => app, () => {
|
||||
groups.value = (app.groups ?? [])
|
||||
.map((g) => {
|
||||
const group = groupsStore.getGroupById(g)
|
||||
if (!group) {
|
||||
groupsStore.searchGroups(g)
|
||||
}
|
||||
return group ?? { id: g, displayName: g, isNoUser: true }
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
const availableGroups = computed(() => groupsStore.groups.filter((group) => !groups.value.includes(group)))
|
||||
const onSearch = useDebounceFn(groupsStore.searchGroups, 400)
|
||||
|
||||
/**
|
||||
* Save the limitation of this app
|
||||
*/
|
||||
async function onSave() {
|
||||
try {
|
||||
loading.value = true
|
||||
await store.limitAppToGroups(app.id, groups.value.map((g) => g.id))
|
||||
emit('close')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reset
|
||||
*/
|
||||
async function onReset() {
|
||||
try {
|
||||
loading.value = true
|
||||
await store.limitAppToGroups(app.id, [])
|
||||
emit('close')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcDialog
|
||||
isForm
|
||||
:name="t('appstore', 'Limit to groups')"
|
||||
@submit="onSave"
|
||||
@reset="onReset">
|
||||
<p>{{ t('appstore', 'Restrict the usage of {app} to members of the following groups.', { app: app.name }) }}</p>
|
||||
<NcSelectUsers
|
||||
v-model="groups"
|
||||
:class="$style.limitToGroupDialog__input"
|
||||
keepOpen
|
||||
labelOutside
|
||||
multiple
|
||||
:options="availableGroups"
|
||||
@search="onSearch" />
|
||||
|
||||
<template #actions>
|
||||
<NcButton :disabled="loading" type="reset">
|
||||
{{ t('appstore', 'Reset limitation') }}
|
||||
</NcButton>
|
||||
<NcButton :disabled="loading" type="submit" variant="primary">
|
||||
<template v-if="loading" #icon>
|
||||
<NcLoadingIcon />
|
||||
</template>
|
||||
|
||||
{{ t('appstore', 'Save') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.limitToGroupDialog__input {
|
||||
width: 100%;
|
||||
padding-block: 1lh calc(2 * var(--default-clickable-area) + var(--default-grid-baseline));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,120 +6,14 @@
|
|||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiAlertCircleCheckOutline, mdiCheck, mdiClose, mdiDownload, mdiTrashCanOutline, mdiUpdate } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { useUpdatesStore } from '../store/updates.ts'
|
||||
import { canDisable, canEnable, canInstall, canUninstall, canUpdate, needForceEnable } from '../utils/appStatus.ts'
|
||||
|
||||
type AppAction = {
|
||||
id: string
|
||||
icon: string
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => string
|
||||
callback: (app: IAppstoreApp | IAppstoreExApp) => Promise<void>
|
||||
variant?: 'primary' | 'error' | 'warning'
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
const AppAction = Object.freeze({
|
||||
INSTALL: {
|
||||
id: 'install',
|
||||
icon: mdiDownload,
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => {
|
||||
if (app.app_api) {
|
||||
return t('appstore', 'Deploy and enable')
|
||||
}
|
||||
if (app.needsDownload) {
|
||||
return t('appstore', 'Download and enable')
|
||||
}
|
||||
return t('appstore', 'Install and enable')
|
||||
},
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
ENABLE: {
|
||||
id: 'enable',
|
||||
icon: mdiCheck,
|
||||
variant: 'primary',
|
||||
label: () => t('appstore', 'Enable'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
FORCE_ENABLE: {
|
||||
id: 'force-enable',
|
||||
icon: mdiAlertCircleCheckOutline,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Force enable'),
|
||||
variant: 'warning',
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.forceEnableApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
DISABLE: {
|
||||
id: 'disable',
|
||||
icon: mdiClose,
|
||||
label: () => t('appstore', 'Disable'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.disableApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
REMOVE: {
|
||||
id: 'remove',
|
||||
icon: mdiTrashCanOutline,
|
||||
variant: 'error',
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Remove'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.uninstallApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
UPDATE: {
|
||||
id: 'update',
|
||||
icon: mdiUpdate,
|
||||
variant: 'primary',
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => t('appstore', 'Update to {version}', { version: app.update! }),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useUpdatesStore()
|
||||
await store.updateApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
})
|
||||
import { actions } from '../actions/index.ts'
|
||||
|
||||
/**
|
||||
* Get the available actions for an app
|
||||
*
|
||||
* @param app - The app to get the actions for
|
||||
*/
|
||||
export function useActions(app: MaybeRefOrGetter<IAppstoreApp | IAppstoreExApp>) {
|
||||
return computed(() => {
|
||||
const actions: typeof AppAction[keyof typeof AppAction][] = []
|
||||
if (canUpdate(toValue(app))) {
|
||||
actions.push(AppAction.UPDATE)
|
||||
}
|
||||
|
||||
if (canDisable(toValue(app))) {
|
||||
actions.push(AppAction.DISABLE)
|
||||
}
|
||||
|
||||
if (needForceEnable(toValue(app))) {
|
||||
actions.push(AppAction.FORCE_ENABLE)
|
||||
} else if (canInstall(toValue(app))) {
|
||||
actions.push(AppAction.INSTALL)
|
||||
} else if (canEnable(toValue(app))) {
|
||||
actions.push(AppAction.ENABLE)
|
||||
}
|
||||
|
||||
if (canUninstall(toValue(app))) {
|
||||
actions.push(AppAction.REMOVE)
|
||||
}
|
||||
return actions
|
||||
})
|
||||
export function useActions(app: MaybeRefOrGetter<IAppstoreApp | IAppstoreExApp | null>) {
|
||||
return computed(() => toValue(app) ? actions.filter((action) => action.enabled(toValue(app)!)) : [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ function markedLink({ href, title, text }: Tokens.Link) {
|
|||
if (title) {
|
||||
out += ' title="' + title + '"'
|
||||
}
|
||||
out += '>' + text + '</a>'
|
||||
out += '>' + text.replaceAll(/(?<!\\)\\([^\\])/g, '$1') + '</a>'
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import 'vite/modulepreload-polyfill'
|
|||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(AppstoreApp)
|
||||
app.config.idPrefix = 'appstore'
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#content')
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
|
||||
|
||||
const productName = window.OC.theme.productName
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
appGroups() {
|
||||
return this.app.groups.map((group) => {
|
||||
return { id: group, name: group }
|
||||
})
|
||||
},
|
||||
installing() {
|
||||
if (this.app?.app_api) {
|
||||
return this.app && this?.appApiStore.getLoading('install') === true
|
||||
}
|
||||
return this.$store.getters.loading('install')
|
||||
},
|
||||
isLoading() {
|
||||
if (this.app?.app_api) {
|
||||
return this.app && this?.appApiStore.getLoading(this.app.id) === true
|
||||
}
|
||||
return this.app && this.$store.getters.loading(this.app.id)
|
||||
},
|
||||
isInitializing() {
|
||||
if (this.app?.app_api) {
|
||||
return this.app && (this.app?.status?.action === 'init' || this.app?.status?.action === 'healthcheck')
|
||||
}
|
||||
return false
|
||||
},
|
||||
isDeploying() {
|
||||
if (this.app?.app_api) {
|
||||
return this.app && this.app?.status?.action === 'deploy'
|
||||
}
|
||||
return false
|
||||
},
|
||||
isManualInstall() {
|
||||
if (this.app?.app_api) {
|
||||
return this.app?.daemon?.accepts_deploy_id === 'manual-install'
|
||||
}
|
||||
return false
|
||||
},
|
||||
updateButtonText() {
|
||||
if (this.app?.app_api && this.app?.daemon?.accepts_deploy_id === 'manual-install') {
|
||||
return t('settings', 'Manually installed apps cannot be updated')
|
||||
}
|
||||
return t('settings', 'Update to {version}', { version: this.app?.update })
|
||||
},
|
||||
enableButtonText() {
|
||||
if (this.app?.app_api) {
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') {
|
||||
return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy ?? 0 })
|
||||
}
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
|
||||
return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init ?? 0 })
|
||||
}
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') {
|
||||
return t('settings', 'Health checking')
|
||||
}
|
||||
if (this.app.needsDownload) {
|
||||
return t('settings', 'Deploy and Enable')
|
||||
}
|
||||
return t('settings', 'Enable')
|
||||
} else {
|
||||
if (this.app.needsDownload) {
|
||||
return t('settings', 'Download and enable')
|
||||
}
|
||||
return t('settings', 'Enable')
|
||||
}
|
||||
},
|
||||
disableButtonText() {
|
||||
if (this.app?.app_api) {
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') {
|
||||
return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy })
|
||||
}
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
|
||||
return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init })
|
||||
}
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') {
|
||||
return t('settings', 'Health checking')
|
||||
}
|
||||
}
|
||||
return t('settings', 'Disable')
|
||||
},
|
||||
forceEnableButtonText() {
|
||||
if (this.app.needsDownload) {
|
||||
return t('settings', 'Allow untested app')
|
||||
}
|
||||
return t('settings', 'Allow untested app')
|
||||
},
|
||||
enableButtonTooltip() {
|
||||
if (!this.app?.app_api && this.app.needsDownload) {
|
||||
return t('settings', 'The app will be downloaded from the App Store')
|
||||
}
|
||||
return null
|
||||
},
|
||||
forceEnableButtonTooltip() {
|
||||
const base = t('settings', 'This app is not marked as compatible with your {productName} version.', { productName })
|
||||
+ ' '
|
||||
+ t('settings', 'If you continue you will still be able to install the app. Note that the app might not work as expected.')
|
||||
if (this.app.needsDownload) {
|
||||
return base + ' ' + t('settings', 'The app will be downloaded from the App Store')
|
||||
}
|
||||
return base
|
||||
},
|
||||
defaultDeployDaemonAccessible() {
|
||||
if (this.app?.app_api) {
|
||||
if (this.app?.daemon && this.app?.daemon?.accepts_deploy_id === 'manual-install') {
|
||||
return true
|
||||
}
|
||||
if (this.app?.daemon?.accepts_deploy_id === 'docker-install'
|
||||
&& this.appApiStore.getDefaultDaemon?.name === this.app?.daemon?.name) {
|
||||
return this?.appApiStore.getDaemonAccessible === true
|
||||
}
|
||||
return this?.appApiStore.getDaemonAccessible
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
groupCheckedAppsData: false,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.app && this.app.groups && this.app.groups.length > 0) {
|
||||
this.groupCheckedAppsData = true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
asyncFindGroup(query) {
|
||||
return this.$store.dispatch('getGroups', { search: query, limit: 5, offset: 0 })
|
||||
},
|
||||
isLimitedToGroups() {
|
||||
if (this.app?.app_api) {
|
||||
return false
|
||||
}
|
||||
return this.app.groups.length || this.groupCheckedAppsData
|
||||
},
|
||||
setGroupLimit() {
|
||||
if (this.app?.app_api) {
|
||||
return // not supported for app_api apps
|
||||
}
|
||||
if (!this.groupCheckedAppsData) {
|
||||
this.$store.dispatch('enableApp', { appId: this.app.id, groups: [] })
|
||||
}
|
||||
},
|
||||
canLimitToGroups(app) {
|
||||
if ((app.types && app.types.includes('filesystem'))
|
||||
|| app.types.includes('prelogin')
|
||||
|| app.types.includes('authentication')
|
||||
|| app.types.includes('logging')
|
||||
|| app.types.includes('prevent_group_restriction')
|
||||
|| app?.app_api) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
addGroupLimitation(groupArray) {
|
||||
if (this.app?.app_api) {
|
||||
return
|
||||
}
|
||||
const group = groupArray.pop()
|
||||
const groups = this.app.groups.concat([]).concat([group.id])
|
||||
|
||||
if (this.store && this.store.updateAppGroups) {
|
||||
this.store.updateAppGroups(this.app.id, groups)
|
||||
}
|
||||
|
||||
this.$store.dispatch('enableApp', { appId: this.app.id, groups })
|
||||
},
|
||||
removeGroupLimitation(group) {
|
||||
if (this.app?.app_api) {
|
||||
return
|
||||
}
|
||||
const currentGroups = this.app.groups.concat([])
|
||||
const index = currentGroups.indexOf(group.id)
|
||||
if (index > -1) {
|
||||
currentGroups.splice(index, 1)
|
||||
}
|
||||
|
||||
if (this.store && this.store.updateAppGroups) {
|
||||
this.store.updateAppGroups(this.app.id, currentGroups)
|
||||
}
|
||||
|
||||
if (currentGroups.length === 0) {
|
||||
this.groupCheckedAppsData = false
|
||||
}
|
||||
|
||||
this.$store.dispatch('enableApp', { appId: this.app.id, groups: currentGroups })
|
||||
},
|
||||
forceEnable(appId) {
|
||||
if (this.app?.app_api) {
|
||||
this.appApiStore.forceEnableApp(appId)
|
||||
.then(() => { rebuildNavigation() })
|
||||
.catch((error) => { showError(error) })
|
||||
} else {
|
||||
this.$store.dispatch('forceEnableApp', { appId, groups: [] })
|
||||
.then(() => { rebuildNavigation() })
|
||||
.catch((error) => { showError(error) })
|
||||
}
|
||||
},
|
||||
enable(appId, daemon = null, deployOptions = {}) {
|
||||
if (this.app?.app_api) {
|
||||
this.appApiStore.enableApp(appId, daemon, deployOptions)
|
||||
.then(() => { rebuildNavigation() })
|
||||
.catch((error) => { showError(error) })
|
||||
} else {
|
||||
this.$store.dispatch('enableApp', { appId, groups: [] })
|
||||
.then(() => { rebuildNavigation() })
|
||||
.catch((error) => { showError(error) })
|
||||
}
|
||||
},
|
||||
disable(appId) {
|
||||
if (this.app?.app_api) {
|
||||
this.appApiStore.disableApp(appId)
|
||||
.then(() => { rebuildNavigation() })
|
||||
.catch((error) => { showError(error) })
|
||||
} else {
|
||||
this.$store.dispatch('disableApp', { appId })
|
||||
.then(() => { rebuildNavigation() })
|
||||
.catch((error) => { showError(error) })
|
||||
}
|
||||
},
|
||||
async remove(appId, removeData = false) {
|
||||
try {
|
||||
if (this.app?.app_api) {
|
||||
await this.appApiStore.uninstallApp(appId, removeData)
|
||||
} else {
|
||||
await this.$store.dispatch('uninstallApp', { appId, removeData })
|
||||
}
|
||||
await rebuildNavigation()
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
}
|
||||
},
|
||||
install(appId) {
|
||||
if (this.app?.app_api) {
|
||||
this.appApiStore.enableApp(appId)
|
||||
.then(() => { rebuildNavigation() })
|
||||
.catch((error) => { showError(error) })
|
||||
} else {
|
||||
this.$store.dispatch('enableApp', { appId })
|
||||
.then(() => { rebuildNavigation() })
|
||||
.catch((error) => { showError(error) })
|
||||
}
|
||||
},
|
||||
update(appId) {
|
||||
if (this.app?.app_api) {
|
||||
return this.appApiStore.updateApp(appId)
|
||||
.then(() => { rebuildNavigation() })
|
||||
.catch((error) => { showError(error) })
|
||||
} else {
|
||||
return this.$store.dispatch('updateApp', { appId })
|
||||
.catch((error) => { showError(error) })
|
||||
.then(() => {
|
||||
rebuildNavigation()
|
||||
this.store.updateCount = Math.max(this.store.updateCount - 1, 0)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -11,9 +11,9 @@ import { defineAsyncComponent } from 'vue'
|
|||
const appstoreEnabled = loadState<boolean>('appstore', 'appstoreEnabled', true)
|
||||
|
||||
// Dynamic loading
|
||||
const AppstoreDiscover = defineAsyncComponent(() => import('../views/AppstoreDiscover.vue'))
|
||||
const AppstoreManage = defineAsyncComponent(() => import('../views/AppstoreManage.vue'))
|
||||
const AppstoreBundles = defineAsyncComponent(() => import('../views/AppstoreBundles.vue'))
|
||||
const AppstoreDiscover = () => import('../views/AppstoreDiscover.vue')
|
||||
const AppstoreManage = () => import('../views/AppstoreManage.vue')
|
||||
const AppstoreBundles = () => import('../views/AppstoreBundles.vue')
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -32,10 +32,11 @@ const queue = new PQueue({ concurrency: 1 })
|
|||
*
|
||||
* @param appId - The app to enable
|
||||
* @param force - Whether to force enable the app
|
||||
* @param groups - The groups to enable the app for
|
||||
*/
|
||||
export async function enableApp(appId: string, force = false) {
|
||||
export async function enableApp(appId: string, force = false, groups?: string[]) {
|
||||
return queue.add(async () => {
|
||||
await axios.post(Url.enable, { appId, force: force || undefined }, { confirmPassword: PwdConfirmationMode.Strict })
|
||||
await axios.post(Url.enable, { appId, groups, force: force || undefined }, { confirmPassword: PwdConfirmationMode.Strict })
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
*/
|
||||
|
||||
import type { IAppstoreExApp, IDeployDaemon, IDeployOptions } from '../apps.d.ts'
|
||||
import type { IAppstoreExApp, IDeployDaemon, IDeployOptions, IExAppStatus } from '../apps.d.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
|
|
@ -17,6 +17,16 @@ export async function fetchApps() {
|
|||
return data.apps as IAppstoreExApp[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of an external app.
|
||||
*
|
||||
* @param appId - The app to fetch
|
||||
*/
|
||||
export async function fetchAppStatus(appId: string) {
|
||||
const { data } = await axios.get<IExAppStatus>(generateUrl(`/apps/app_api/apps/status/${appId}`))
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable an external app.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { defineStore } from 'pinia'
|
|||
import { computed, readonly, ref } from 'vue'
|
||||
import * as api from '../service/api.ts'
|
||||
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
|
||||
import { canDisable, canInstall, canUninstall, needForceEnable } from '../utils/appStatus.ts'
|
||||
import { canDisable, canInstall, canLimitToGroups, canUninstall, needForceEnable } from '../utils/appStatus.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
import { useExAppsStore } from './exApps.ts'
|
||||
|
||||
|
|
@ -132,6 +132,8 @@ export const useAppsStore = defineStore('apps', () => {
|
|||
await api.disableApp(appId)
|
||||
}
|
||||
app.active = false
|
||||
// revert "force enable"
|
||||
app.isCompatible = app.missingDependencies === undefined || app.missingDependencies.length === 0
|
||||
await rebuildNavigation()
|
||||
} finally {
|
||||
app.loading = false
|
||||
|
|
@ -169,15 +171,31 @@ export const useAppsStore = defineStore('apps', () => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the groups of an app
|
||||
* Limit access to an app to specific groups
|
||||
*
|
||||
* @param appId - The app to update
|
||||
* @param groups - The new groups
|
||||
* @param appId - The app to limit access to
|
||||
* @param groups - The groups which should have access
|
||||
*/
|
||||
function updateAppGroups(appId: string, groups: string[]) {
|
||||
const app = apps.value.find(({ id }) => id === appId)
|
||||
if (app) {
|
||||
app.groups = [...groups]
|
||||
async function limitAppToGroups(appId: string, groups: string[]) {
|
||||
const app = getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
if (!canLimitToGroups(app)) {
|
||||
throw new Error(`App with id ${appId} cannot be limited to groups`)
|
||||
}
|
||||
|
||||
if (app.app_api) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
app.loading = true
|
||||
await api.enableApp(appId, false, groups)
|
||||
app.groups = groups
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,6 +280,6 @@ export const useAppsStore = defineStore('apps', () => {
|
|||
getAppById,
|
||||
getAppsByCategory,
|
||||
getCategoryById,
|
||||
updateAppGroups,
|
||||
limitAppToGroups,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -33,14 +33,30 @@ export const useExAppsStore = defineStore('external-apps', () => {
|
|||
*/
|
||||
const updateCount = ref(loadState('appstore', 'appstoreExAppUpdateCount', 0))
|
||||
|
||||
const statusUpdater = ref<number | null | undefined>(null)
|
||||
/**
|
||||
* The interval ID for the status updater
|
||||
*/
|
||||
let statusUpdater: number | null = null
|
||||
|
||||
/**
|
||||
* Whether at least one of the configured daemons is accessible.
|
||||
*/
|
||||
const daemonAccessible = ref(loadState('appstore', 'defaultDaemonConfigAccessible', false))
|
||||
|
||||
/**
|
||||
* The default daemon, used for apps that don't specify a daemon or have a daemon that is not accessible.
|
||||
*/
|
||||
const defaultDaemon = ref(loadState<IDeployDaemon | null>('appstore', 'defaultDaemonConfig', null))
|
||||
|
||||
/**
|
||||
* The list of daemons that support docker-based deployment, used to show the daemon selection when enabling an app.
|
||||
*/
|
||||
const dockerDaemons = ref<IDeployDaemon[]>([])
|
||||
|
||||
const initializingOrDeployingApps = computed(() => apps.value.filter((app) => app?.status?.action
|
||||
&& (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck')
|
||||
&& app.status.type !== ''))
|
||||
const initializingOrDeployingApps = computed(() => apps.value
|
||||
.filter((app) => app?.status?.action
|
||||
&& app.status.type !== ''
|
||||
&& (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck')))
|
||||
|
||||
/**
|
||||
* Get an external app by its ID
|
||||
|
|
@ -89,6 +105,8 @@ export const useExAppsStore = defineStore('external-apps', () => {
|
|||
}
|
||||
app.removable = true
|
||||
delete app.error
|
||||
|
||||
await fetchAppStatus(appId)
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
|
|
@ -186,8 +204,8 @@ export const useExAppsStore = defineStore('external-apps', () => {
|
|||
delete app.update
|
||||
delete app.error
|
||||
updateCount.value--
|
||||
// Trigger status updates
|
||||
// updateAppsStatus()
|
||||
|
||||
await fetchAppStatus(appId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update ex app', { appId, error })
|
||||
showError(t('appstore', 'Could not update the app. Please try again later.'))
|
||||
|
|
@ -218,6 +236,7 @@ export const useExAppsStore = defineStore('external-apps', () => {
|
|||
updateCount,
|
||||
defaultDaemon,
|
||||
dockerDaemons,
|
||||
daemonAccessible,
|
||||
|
||||
getById,
|
||||
disableApp,
|
||||
|
|
@ -255,48 +274,54 @@ export const useExAppsStore = defineStore('external-apps', () => {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
async fetchAppStatus(appId: string) {
|
||||
return api.get(generateUrl(`/apps/app_api/apps/status/${appId}`))
|
||||
.then((response) => {
|
||||
const app = this.apps.find((app) => app.id === appId)
|
||||
if (app) {
|
||||
app.status = response.data
|
||||
}
|
||||
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
|
||||
logger.debug('initializingOrDeployingApps after setAppStatus', { initializingOrDeployingApps })
|
||||
if (initializingOrDeployingApps.length === 0) {
|
||||
logger.debug('clearing interval')
|
||||
clearInterval(this.statusUpdater as number)
|
||||
this.statusUpdater = null
|
||||
}
|
||||
if (Object.hasOwn(response.data, 'error')
|
||||
&& response.data.error !== ''
|
||||
&& initializingOrDeployingApps.length === 1) {
|
||||
clearInterval(this.statusUpdater as number)
|
||||
this.statusUpdater = null
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.appsApiFailure({ appId, error })
|
||||
this.apps = this.apps.filter((app) => app.id !== appId)
|
||||
this.updateAppsStatus()
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Get the status of an external app.
|
||||
*
|
||||
* @param appId - The app ID to fetch the status for
|
||||
*/
|
||||
async function fetchAppStatus(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
logger.error('[app-api-store] app not found while fetching status', { appId })
|
||||
return
|
||||
}
|
||||
|
||||
updateAppsStatus() {
|
||||
clearInterval(this.statusUpdater as number)
|
||||
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
|
||||
if (initializingOrDeployingApps.length === 0) {
|
||||
return
|
||||
app.loading = true
|
||||
try {
|
||||
const status = await exAppApi.fetchAppStatus(appId)
|
||||
app.status = status
|
||||
logger.debug('[app-api-store] initializingOrDeployingApps after setAppStatus', { initializingOrDeployingApps })
|
||||
if (initializingOrDeployingApps.value.length === 0) {
|
||||
logger.debug('[app-api-store] Clearing interval')
|
||||
clearInterval(statusUpdater as number)
|
||||
statusUpdater = null
|
||||
}
|
||||
this.statusUpdater = setInterval(() => {
|
||||
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
|
||||
logger.debug('initializingOrDeployingApps', { initializingOrDeployingApps })
|
||||
initializingOrDeployingApps.forEach((app) => {
|
||||
this.fetchAppStatus(app.id)
|
||||
})
|
||||
}, 2000) as unknown as number
|
||||
},
|
||||
}, */
|
||||
if (app.status.error && initializingOrDeployingApps.value.length === 1) {
|
||||
clearInterval(statusUpdater as number)
|
||||
statusUpdater = null
|
||||
}
|
||||
} catch (e) {
|
||||
updateAppsStatus()
|
||||
throw e
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of all apps that are currently initializing or deploying
|
||||
*/
|
||||
function updateAppsStatus() {
|
||||
clearInterval(statusUpdater as number)
|
||||
if (initializingOrDeployingApps.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdater = window.setInterval(() => {
|
||||
logger.debug('[app-api-store] initializingOrDeployingApps', { initializingOrDeployingApps })
|
||||
for (const app of initializingOrDeployingApps.value) {
|
||||
fetchAppStatus(app.id)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
63
apps/appstore/src/store/groups.ts
Normal file
63
apps/appstore/src/store/groups.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
*/
|
||||
|
||||
import type { OCSResponse } from '@nextcloud/typings/ocs'
|
||||
import type { NcSelectUsersModel } from '@nextcloud/vue/components/NcSelectUsers'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
export const useGroupsStore = defineStore('groups', () => {
|
||||
const groups = ref(new Map<string, NcSelectUsersModel>())
|
||||
|
||||
/**
|
||||
* Search the API for groups matching the query
|
||||
*
|
||||
* @param query - Query to search
|
||||
*/
|
||||
async function searchGroups(query: string) {
|
||||
const url = generateOcsUrl('/cloud/groups/details')
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data } = await axios.get<OCSResponse<{ groups: any }>>(url, {
|
||||
params: {
|
||||
search: query.trim(),
|
||||
limit: 10,
|
||||
},
|
||||
})
|
||||
for (const group of data.ocs.data.groups) {
|
||||
if (groups.value.has(group.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
groups.value.set(group.id, {
|
||||
id: group.id,
|
||||
displayName: group.displayname,
|
||||
isNoUser: true,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to search groups', { error })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a group by its id
|
||||
*
|
||||
* @param groupId - The id of the group to retrieve
|
||||
*/
|
||||
function getGroupById(groupId: string) {
|
||||
return groups.value.get(groupId)
|
||||
}
|
||||
|
||||
return {
|
||||
groups: computed(() => Array.from(groups.value.values())),
|
||||
searchGroups,
|
||||
getGroupById,
|
||||
}
|
||||
})
|
||||
|
|
@ -42,7 +42,7 @@ export function canUninstall(app: IAppstoreApp | IAppstoreExApp) {
|
|||
* @param app - The app to check
|
||||
*/
|
||||
export function canEnable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return canForceEnable(app) && app.isCompatible
|
||||
return !isInitializing(app) && !isDeploying(app) && canForceEnable(app) && app.isCompatible
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -69,7 +69,7 @@ export function needForceEnable(app: IAppstoreApp | IAppstoreExApp) {
|
|||
* @param app - The app to check
|
||||
*/
|
||||
export function canDisable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.active && !app.internal
|
||||
return !isInitializing(app) && !isDeploying(app) && app.active && !app.internal
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,3 +80,46 @@ export function canDisable(app: IAppstoreApp | IAppstoreExApp) {
|
|||
export function canUpdate(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.update !== undefined
|
||||
}
|
||||
|
||||
const restrictedTypes = ['filesystem', 'prelogin', 'authentication', 'logging', 'prevent_group_restriction']
|
||||
|
||||
/**
|
||||
* Check if an app can be limited to groups
|
||||
*
|
||||
* @param app - The app to check if can be limited to groups
|
||||
*/
|
||||
export function canLimitToGroups(app: IAppstoreApp | IAppstoreExApp) {
|
||||
if (!app.active && !app.installed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!app.active && needForceEnable(app)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!app.types) {
|
||||
return true
|
||||
}
|
||||
|
||||
return app.types.every((type) => !restrictedTypes.includes(type))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app is currently being initialized.
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
function isInitializing(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.app_api
|
||||
&& (app.status.action === 'init' || app.status.action === 'healthcheck')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app is currently being deployed.
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
function isDeploying(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.app_api
|
||||
&& app.status.action === 'deploy'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,162 +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" />
|
||||
<AppDaemonBadge v-if="app.app_api && app.daemon" :daemon="app.daemon" />
|
||||
<AppScore v-if="hasRating" :score="rating" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tab content -->
|
||||
<AppDescriptionTab :app="app" />
|
||||
<AppDetailsTab :key="app.id" :app="app" />
|
||||
<AppReleasesTab :app="app" />
|
||||
<AppDeployDaemonTab :app="app" />
|
||||
</NcAppSidebar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router/composables'
|
||||
import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import AppDaemonBadge from '../components/AppList/AppDaemonBadge.vue'
|
||||
import AppLevelBadge from '../components/AppList/AppLevelBadge.vue'
|
||||
import AppScore from '../components/AppList/AppScore.vue'
|
||||
import AppDeployDaemonTab from '../components/AppStoreSidebar/AppDeployDaemonTab.vue'
|
||||
import AppDescriptionTab from '../components/AppStoreSidebar/AppDescriptionTab.vue'
|
||||
import AppDetailsTab from '../components/AppStoreSidebar/AppDetailsTab.vue'
|
||||
import AppReleasesTab from '../components/AppStoreSidebar/AppReleasesTab.vue'
|
||||
import { useAppIcon } from '../composables/useAppIcon.ts'
|
||||
import { useAppApiStore } from '../store/app-api-store.ts'
|
||||
import { useAppsStore } from '../store/apps-store.ts'
|
||||
import { useStore } from '../store/index.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppsStore()
|
||||
const appApiStore = useAppApiStore()
|
||||
const legacyStore = useStore()
|
||||
|
||||
const appId = computed(() => route.params.id ?? '')
|
||||
const app = computed(() => {
|
||||
if (legacyStore.getters.isAppApiEnabled) {
|
||||
const exApp = appApiStore.getAllApps
|
||||
.find((app) => app.id === appId.value) ?? null
|
||||
if (exApp) {
|
||||
return exApp
|
||||
}
|
||||
}
|
||||
return 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(() => {
|
||||
if (!app.value) {
|
||||
return ''
|
||||
}
|
||||
if (app.value.license !== '') {
|
||||
return t('settings', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.license.toString().toUpperCase() })
|
||||
}
|
||||
return t('settings', 'Version {version}', { version: app.value.version })
|
||||
})
|
||||
|
||||
const activeTab = ref('details')
|
||||
watch([app], () => {
|
||||
activeTab.value = 'details'
|
||||
})
|
||||
|
||||
/**
|
||||
* Hide the details sidebar by pushing a new route
|
||||
*/
|
||||
function 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
|
||||
*/
|
||||
function 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>
|
||||
137
apps/appstore/src/views/AppstoreSidebar.vue
Normal file
137
apps/appstore/src/views/AppstoreSidebar.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import AppActions from '../components/AppActions.vue'
|
||||
import AppDeployDaemonTab from '../components/AppstoreSidebar/AppDeployDaemonTab.vue'
|
||||
import AppDescriptionTab from '../components/AppstoreSidebar/AppDescriptionTab.vue'
|
||||
import AppDetailsTab from '../components/AppstoreSidebar/AppDetailsTab.vue'
|
||||
import AppReleasesTab from '../components/AppstoreSidebar/AppReleasesTab.vue'
|
||||
import { useActions } from '../composables/useActions.ts'
|
||||
import { useAppIcon } from '../composables/useAppIcon.ts'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppsStore()
|
||||
|
||||
const appId = computed<string>(() => [route.params.id].flat()[0]!)
|
||||
const app = computed(() => store.getAppById(appId.value) ?? null)
|
||||
const { appIcon } = useAppIcon(app)
|
||||
|
||||
/**
|
||||
* The second text line shown on the sidebar
|
||||
*/
|
||||
const licenseText = computed(() => {
|
||||
if (!app.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (app.value.license) {
|
||||
return t('appstore', 'Version {version}, {license}-licensed', { version: app.value.version, license: String(app.value.license).toUpperCase() })
|
||||
}
|
||||
return t('appstore', 'Version {version}', { version: app.value.version })
|
||||
})
|
||||
|
||||
const activeTab = ref('details')
|
||||
watch([app], () => {
|
||||
activeTab.value = 'details'
|
||||
})
|
||||
|
||||
/**
|
||||
* Hide the details sidebar by pushing a new route
|
||||
*/
|
||||
function hideAppDetails() {
|
||||
router.replace({
|
||||
name: route.name!,
|
||||
params: {
|
||||
...route.params,
|
||||
id: undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app screenshot is loaded
|
||||
*/
|
||||
const screenshotLoaded = ref(false)
|
||||
const hasScreenshot = computed(() => app.value?.screenshot && screenshotLoaded.value)
|
||||
/**
|
||||
* Preload the app screenshot
|
||||
*/
|
||||
function 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)
|
||||
|
||||
const actions = useActions(() => app.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Selected app details -->
|
||||
<NcAppSidebar
|
||||
v-model:active="activeTab"
|
||||
:class="[$style.appstoreSidebar, { [$style.appstoreSidebar_withScreenshot]: hasScreenshot }]"
|
||||
:background="hasScreenshot ? app!.screenshot : undefined"
|
||||
:compact="!hasScreenshot"
|
||||
:name="app?.name ?? appId"
|
||||
:title="app?.name ?? appId"
|
||||
:subname="licenseText"
|
||||
:subtitle="licenseText"
|
||||
@close="hideAppDetails">
|
||||
<!-- Fallback icon in case no app icon is available -->
|
||||
<template v-if="!hasScreenshot" #header>
|
||||
<NcIconSvgWrapper
|
||||
:class="$style.appstoreSidebar__fallbackIcon"
|
||||
:svg="appIcon ?? ''"
|
||||
:size="64" />
|
||||
</template>
|
||||
|
||||
<template v-if="app" #description>
|
||||
<AppActions
|
||||
:app
|
||||
:actions
|
||||
iconOnly
|
||||
:maxInlineActions="6" />
|
||||
</template>
|
||||
|
||||
<!-- Tab content -->
|
||||
<NcEmptyContent v-if="!app" name="No such app" />
|
||||
<template v-else>
|
||||
<AppDescriptionTab :app />
|
||||
<AppReleasesTab :app />
|
||||
<AppDetailsTab :app />
|
||||
<AppDeployDaemonTab v-if="app.app_api" :app />
|
||||
</template>
|
||||
</NcAppSidebar>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
/* If a screenshot is available it should cover the whole figure */
|
||||
.appstoreSidebar_withScreenshot {
|
||||
:global(.app-sidebar-header__figure) {
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.appstoreSidebar__fallbackIcon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -470,6 +470,7 @@ class OC_App {
|
|||
}
|
||||
}
|
||||
|
||||
$info['license'] ??= $info['licence'];
|
||||
$info['version'] = $appManager->getAppVersion($app);
|
||||
$info['license'] ??= $info['licence'];
|
||||
$appList[] = $info;
|
||||
|
|
|
|||
Loading…
Reference in a new issue