mirror of
https://github.com/nextcloud/server.git
synced 2026-05-22 18:16:36 -04:00
Merge pull request #57290 from nextcloud/refactor/split-appstore
refactor(appstore): migrate to Typescript and Vue 3
This commit is contained in:
commit
387b40bcfe
629 changed files with 6057 additions and 6346 deletions
|
|
@ -82,12 +82,13 @@ class ApiController extends OCSController {
|
|||
/**
|
||||
* Get all available apps
|
||||
*
|
||||
* @param bool $details - Whether to include detailed appstore information about the app
|
||||
* @return DataResponse<Http::STATUS_OK, list<array{id: string, name: string, groups: list<string>, internal: bool, isCompatible: bool, missingDependencies?: list<string>, missingMaxNextcloudVersion: bool, missingMinNextcloudVersion: bool, ...<array-key, mixed>}>, array{}>
|
||||
*
|
||||
* 200: The apps were found successfully
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/v1/apps')]
|
||||
public function listApps(): DataResponse {
|
||||
public function listApps(bool $details = false): DataResponse {
|
||||
$apps = $this->getAllApps();
|
||||
|
||||
/** @var array<string>|mixed $ignoreMaxApps */
|
||||
|
|
@ -98,12 +99,16 @@ class ApiController extends OCSController {
|
|||
}
|
||||
|
||||
// Extend existing app details
|
||||
$apps = array_map(function (array $appData) use ($ignoreMaxApps): array {
|
||||
$apps = array_map(function (array $appData) use ($ignoreMaxApps, $details): array {
|
||||
if (isset($appData['appstoreData'])) {
|
||||
$appstoreData = $appData['appstoreData'];
|
||||
$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
|
||||
$appData['category'] = $appstoreData['categories'];
|
||||
$appData['releases'] = $appstoreData['releases'];
|
||||
|
||||
if (!$details) {
|
||||
unset($appData['appstoreData']);
|
||||
}
|
||||
}
|
||||
|
||||
$newVersion = $this->installer->isUpdateAvailable($appData['id']);
|
||||
|
|
@ -123,17 +128,15 @@ class ApiController extends OCSController {
|
|||
}
|
||||
|
||||
$appData['groups'] = $groups;
|
||||
$appData['canUninstall'] = !$appData['active'] && $appData['removable'];
|
||||
|
||||
// analyze dependencies
|
||||
$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
|
||||
$missing = $this->dependencyAnalyzer->analyze($appData, $ignoreMax);
|
||||
$appData['canInstall'] = empty($missing);
|
||||
$appData['missingDependencies'] = $missing;
|
||||
|
||||
$appData['missingMinNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
|
||||
$appData['missingMaxNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
|
||||
$appData['isCompatible'] = $this->dependencyAnalyzer->isMarkedCompatible($appData);
|
||||
$appData['internal'] = in_array($appData['id'], $this->appManager->getAlwaysEnabledApps());
|
||||
|
||||
return $appData;
|
||||
}, $apps);
|
||||
|
|
@ -204,6 +207,7 @@ class ApiController extends OCSController {
|
|||
public function disableApp(string $appId): DataResponse {
|
||||
try {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
$this->appManager->removeOverwriteNextcloudRequirement($appId);
|
||||
$this->appManager->disableApp($appId);
|
||||
return new DataResponse([]);
|
||||
} catch (\Exception $exception) {
|
||||
|
|
@ -214,7 +218,6 @@ class ApiController extends OCSController {
|
|||
|
||||
/**
|
||||
* Uninstall an app.
|
||||
* This will disable the app - if needed - and then remove the app from the system
|
||||
*
|
||||
* @param string $appId - The app to uninstall
|
||||
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
|
||||
|
|
@ -226,6 +229,10 @@ class ApiController extends OCSController {
|
|||
#[ApiRoute(verb: 'POST', url: '/api/v1/apps/uninstall')]
|
||||
public function uninstallApp(string $appId): DataResponse {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
if ($this->appManager->isEnabledForAnyone($appId)) {
|
||||
$this->disableApp($appId);
|
||||
}
|
||||
|
||||
$result = $this->installer->removeApp($appId);
|
||||
if ($result !== false) {
|
||||
// If this app was force enabled, remove the force-enabled-state
|
||||
|
|
@ -452,6 +459,7 @@ class ApiController extends OCSController {
|
|||
'license' => $app['releases'][0]['licenses'],
|
||||
'author' => $authors,
|
||||
'shipped' => $this->appManager->isShipped($app['id']),
|
||||
'internal' => in_array($app['id'], $this->appManager->getAlwaysEnabledApps()),
|
||||
'version' => $currentVersion,
|
||||
'types' => [],
|
||||
'documentation' => [
|
||||
|
|
@ -468,11 +476,9 @@ class ApiController extends OCSController {
|
|||
'level' => ($app['isFeatured'] === true) ? 200 : 100,
|
||||
'missingMaxNextcloudVersion' => false,
|
||||
'missingMinNextcloudVersion' => false,
|
||||
'canInstall' => true,
|
||||
'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,
|
||||
|
|
|
|||
|
|
@ -197,6 +197,15 @@
|
|||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "details",
|
||||
"in": "query",
|
||||
"description": "- Whether to include detailed appstore information about the app",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
|
|
@ -640,7 +649,7 @@
|
|||
"/ocs/v2.php/apps/appstore/api/v1/apps/uninstall": {
|
||||
"post": {
|
||||
"operationId": "api-uninstall-app",
|
||||
"summary": "Uninstall an app. This will disable the app - if needed - and then remove the app from the system",
|
||||
"summary": "Uninstall an app.",
|
||||
"description": "This endpoint requires admin access\nThis endpoint requires password confirmation",
|
||||
"tags": [
|
||||
"api"
|
||||
|
|
|
|||
72
apps/appstore/src/AppstoreApp.vue
Normal file
72
apps/appstore/src/AppstoreApp.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||
import NcContent from '@nextcloud/vue/components/NcContent'
|
||||
import AppstoreNavigation from './views/AppstoreNavigation.vue'
|
||||
import AppstoreSidebar from './views/AppstoreSidebar.vue'
|
||||
import { APPSTORE_CATEGORY_NAMES } from './constants.ts'
|
||||
import { useAppsStore } from './store/apps.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useAppsStore()
|
||||
|
||||
const currentCategory = computed(() => {
|
||||
if (route.params.category) {
|
||||
return [route.params.category].flat()[0]!
|
||||
}
|
||||
if (route.name === 'apps-bundles') {
|
||||
return 'bundles'
|
||||
} else if (route.name === 'apps-search') {
|
||||
return 'search'
|
||||
}
|
||||
return 'discover'
|
||||
})
|
||||
|
||||
const heading = computed(() => {
|
||||
if (currentCategory.value in APPSTORE_CATEGORY_NAMES) {
|
||||
return APPSTORE_CATEGORY_NAMES[currentCategory.value]
|
||||
}
|
||||
return store.getCategoryById(currentCategory.value)?.displayName ?? currentCategory.value
|
||||
})
|
||||
const pageTitle = computed(() => `${heading.value} - ${t('appstore', 'App store')}`)
|
||||
|
||||
const showSidebar = computed(() => !!route.params.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcContent appName="appstore">
|
||||
<AppstoreNavigation />
|
||||
<NcAppContent
|
||||
:class="$style.appstoreApp__content"
|
||||
:pageHeading="t('appstore', 'App store')"
|
||||
:pageTitle>
|
||||
<h2 v-if="heading" :class="$style.appstoreApp__heading">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
<router-view />
|
||||
</NcAppContent>
|
||||
<AppstoreSidebar v-if="showSidebar" />
|
||||
</NcContent>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreApp__content {
|
||||
padding-inline-end: var(--body-container-margin);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.appstoreApp__heading {
|
||||
margin-block-start: var(--app-navigation-padding);
|
||||
margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
|
||||
min-height: var(--default-clickable-area);
|
||||
line-height: var(--default-clickable-area);
|
||||
vertical-align: center;
|
||||
}
|
||||
</style>
|
||||
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)
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface IAppstoreCategory {
|
||||
/**
|
||||
* The category ID
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* The display name (can be localized)
|
||||
*/
|
||||
displayName: string
|
||||
/**
|
||||
* Inline SVG path
|
||||
*/
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface IAppstoreAppRelease {
|
||||
version: string
|
||||
translations: {
|
||||
[key: string]: {
|
||||
changelog: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAppstoreApp {
|
||||
id: string
|
||||
name: string
|
||||
summary: string
|
||||
description: string
|
||||
license: string
|
||||
author: string[] | Record<string, string>
|
||||
level: number
|
||||
version: string
|
||||
category: string | string[]
|
||||
|
||||
preview?: string
|
||||
screenshot?: string
|
||||
|
||||
app_api: boolean
|
||||
active: boolean
|
||||
internal: boolean
|
||||
removable: boolean
|
||||
installed: boolean
|
||||
canInstall: boolean
|
||||
canUnInstall: boolean
|
||||
isCompatible: boolean
|
||||
needsDownload: boolean
|
||||
update?: string
|
||||
|
||||
appstoreData: Record<string, never>
|
||||
releases?: IAppstoreAppRelease[]
|
||||
}
|
||||
|
||||
export interface IComputeDevice {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface IDeployConfig {
|
||||
computeDevice: IComputeDevice
|
||||
net: string
|
||||
nextcloud_url: string
|
||||
}
|
||||
|
||||
export interface IDeployDaemon {
|
||||
accepts_deploy_id: string
|
||||
deploy_config: IDeployConfig
|
||||
display_name: string
|
||||
host: string
|
||||
id: number
|
||||
name: string
|
||||
protocol: string
|
||||
exAppsCount: number
|
||||
}
|
||||
|
||||
export interface IExAppStatus {
|
||||
action: string
|
||||
deploy: number
|
||||
deploy_start_time: number
|
||||
error: string
|
||||
init: number
|
||||
init_start_time: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface IDeployEnv {
|
||||
envName: string
|
||||
displayName: string
|
||||
description: string
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface IDeployMount {
|
||||
hostPath: string
|
||||
containerPath: string
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
export interface IDeployOptions {
|
||||
environment_variables: IDeployEnv[]
|
||||
mounts: IDeployMount[]
|
||||
}
|
||||
|
||||
export interface IAppstoreExAppRelease extends IAppstoreAppRelease {
|
||||
environmentVariables?: IDeployEnv[]
|
||||
}
|
||||
|
||||
export interface IAppstoreExApp extends IAppstoreApp {
|
||||
daemon: IDeployDaemon | null | undefined
|
||||
status: IExAppStatus | Record<string, never>
|
||||
error: string
|
||||
releases: IAppstoreExAppRelease[]
|
||||
}
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
/**
|
||||
* Currently known types of app discover section elements
|
||||
*/
|
||||
export const APP_DISCOVER_KNOWN_TYPES = ['post', 'showcase', 'carousel'] as const
|
||||
|
||||
/**
|
||||
* Helper for localized values
|
||||
*/
|
||||
186
apps/appstore/src/apps.d.ts
vendored
Normal file
186
apps/appstore/src/apps.d.ts
vendored
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface IAppstoreCategory {
|
||||
/**
|
||||
* The category ID
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* The display name (can be localized)
|
||||
*/
|
||||
displayName: string
|
||||
/**
|
||||
* Inline SVG path
|
||||
*/
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface IAppstoreAppRelease {
|
||||
version: string
|
||||
lastModified?: string
|
||||
translations: {
|
||||
[key: string]: {
|
||||
changelog: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type IAppInfoTypes = 'prelogin' | 'filesystem' | 'authentication' | 'extended_authentication' | 'logging' | 'dav' | 'prevent_group_restriction' | 'session'
|
||||
|
||||
/**
|
||||
* 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[]
|
||||
/** The URL of the app's screenshot */
|
||||
screenshot?: string
|
||||
/** The types this app supports */
|
||||
types?: IAppInfoTypes[]
|
||||
|
||||
documentation?: {
|
||||
admin: string
|
||||
user: string
|
||||
developer: string
|
||||
}
|
||||
website?: string
|
||||
discussion?: string
|
||||
bugs?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
/** 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
|
||||
/** User groups this app is limited to */
|
||||
groups?: string[]
|
||||
}
|
||||
|
||||
export interface IAppstoreApp extends IAppstoreAppResponse {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export interface IComputeDevice {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface IDeployConfig {
|
||||
computeDevice: IComputeDevice
|
||||
net: string
|
||||
nextcloud_url: string
|
||||
}
|
||||
|
||||
export interface IDeployDaemon {
|
||||
accepts_deploy_id: string
|
||||
deploy_config: IDeployConfig
|
||||
display_name: string
|
||||
host: string
|
||||
id: number
|
||||
name: string
|
||||
protocol: string
|
||||
exAppsCount: number
|
||||
}
|
||||
|
||||
export interface IExAppStatus {
|
||||
action: string
|
||||
deploy: number
|
||||
deploy_start_time?: number
|
||||
error?: string
|
||||
init: number
|
||||
init_start_time?: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface IDeployEnv {
|
||||
envName: string
|
||||
displayName: string
|
||||
description: string
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface IDeployMount {
|
||||
hostPath: string
|
||||
containerPath: string
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
export interface IDeployOptions {
|
||||
environment_variables: IDeployEnv[]
|
||||
mounts: IDeployMount[]
|
||||
}
|
||||
|
||||
export interface IAppstoreExAppRelease extends IAppstoreAppRelease {
|
||||
environmentVariables?: IDeployEnv[]
|
||||
}
|
||||
|
||||
export interface IAppstoreExApp extends IAppstoreApp {
|
||||
app_api: true
|
||||
daemon: IDeployDaemon | null | undefined
|
||||
status: IExAppStatus | Record<string, never>
|
||||
error?: string
|
||||
releases: IAppstoreExAppRelease[]
|
||||
}
|
||||
|
||||
export interface IAppBundle {
|
||||
id: string
|
||||
name: string
|
||||
appIdentifiers: readonly string[]
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcDialog
|
||||
:open="show"
|
||||
:name="t('settings', 'Choose Deploy Daemon for {appName}', { appName: app.name })"
|
||||
size="normal"
|
||||
@update:open="closeModal">
|
||||
<DaemonSelectionList
|
||||
:app="app"
|
||||
:deploy-options="deployOptions"
|
||||
@close="closeModal" />
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineEmits, defineProps } from 'vue'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import DaemonSelectionList from './DaemonSelectionList.vue'
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
app: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
deployOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show'])
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function closeModal() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcListItem
|
||||
:name="itemTitle"
|
||||
:details="isDefault ? t('settings', 'Default') : ''"
|
||||
:force-display-actions="true"
|
||||
:counter-number="daemon.exAppsCount"
|
||||
:active="isDefault"
|
||||
counter-type="highlighted"
|
||||
@click.stop="selectDaemonAndInstall">
|
||||
<template #subname>
|
||||
{{ daemon.accepts_deploy_id }}
|
||||
</template>
|
||||
</NcListItem>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
import AppManagement from '../../mixins/AppManagement.js'
|
||||
import { useAppApiStore } from '../../store/app-api-store.js'
|
||||
import { useAppsStore } from '../../store/apps-store.js'
|
||||
|
||||
export default {
|
||||
name: 'DaemonSelectionEntry',
|
||||
components: {
|
||||
NcListItem,
|
||||
},
|
||||
|
||||
mixins: [AppManagement], // TODO: Convert to Composition API when AppManagement is refactored
|
||||
props: {
|
||||
daemon: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
isDefault: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
|
||||
app: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
deployOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const store = useAppsStore()
|
||||
const appApiStore = useAppApiStore()
|
||||
|
||||
return {
|
||||
store,
|
||||
appApiStore,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
itemTitle() {
|
||||
return this.daemon.name + ' - ' + this.daemon.display_name
|
||||
},
|
||||
|
||||
daemons() {
|
||||
return this.appApiStore.dockerDaemons
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
closeModal() {
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
selectDaemonAndInstall() {
|
||||
this.closeModal()
|
||||
this.enable(this.app.id, this.daemon, this.deployOptions)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<div class="daemon-selection-list">
|
||||
<ul
|
||||
v-if="dockerDaemons.length > 0"
|
||||
:aria-label="t('settings', 'Registered Deploy daemons list')">
|
||||
<DaemonSelectionEntry
|
||||
v-for="daemon in dockerDaemons"
|
||||
:key="daemon.id"
|
||||
:daemon="daemon"
|
||||
:is-default="defaultDaemon.name === daemon.name"
|
||||
:app="app"
|
||||
:deploy-options="deployOptions"
|
||||
@close="closeModal" />
|
||||
</ul>
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
class="daemon-selection-list__empty-content"
|
||||
:name="t('settings', 'No Deploy daemons configured')"
|
||||
:description="t('settings', 'Register a custom one or setup from available templates')">
|
||||
<template #icon>
|
||||
<FormatListBullet :size="20" />
|
||||
</template>
|
||||
<template #action>
|
||||
<NcButton :href="appApiAdminPage">
|
||||
{{ t('settings', 'Manage Deploy daemons') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { computed, defineProps } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import FormatListBullet from 'vue-material-design-icons/FormatListBulleted.vue'
|
||||
import DaemonSelectionEntry from './DaemonSelectionEntry.vue'
|
||||
import { useAppApiStore } from '../../store/app-api-store.ts'
|
||||
|
||||
defineProps({
|
||||
app: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
deployOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const appApiStore = useAppApiStore()
|
||||
|
||||
const dockerDaemons = computed(() => appApiStore.dockerDaemons)
|
||||
const defaultDaemon = computed(() => appApiStore.defaultDaemon)
|
||||
const appApiAdminPage = computed(() => generateUrl('/settings/admin/app_api'))
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.daemon-selection-list {
|
||||
max-height: 350px;
|
||||
overflow-y: scroll;
|
||||
padding: 2rem;
|
||||
|
||||
&__empty-content {
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
38
apps/appstore/src/components/AppGrid/AppGrid.vue
Normal file
38
apps/appstore/src/components/AppGrid/AppGrid.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!--
|
||||
- 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 { computed } from 'vue'
|
||||
import AppGridItem from './AppGridItem.vue'
|
||||
import { useUserSettingsStore } from '../../store/userSettings.ts'
|
||||
|
||||
defineProps<{
|
||||
apps: (IAppstoreApp | IAppstoreExApp)[]
|
||||
}>()
|
||||
|
||||
const userSettings = useUserSettingsStore()
|
||||
const gridSize = computed(() => userSettings.gridSizePx)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul :class="$style.appGrid">
|
||||
<AppGridItem
|
||||
v-for="app in apps"
|
||||
:key="app.id"
|
||||
:app />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appGrid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: calc(4 * var(--default-grid-baseline));
|
||||
grid-template-columns: repeat(auto-fit, minmax(v-bind(gridSize), 1fr));
|
||||
padding-inline-start: var(--app-navigation-padding);
|
||||
}
|
||||
</style>
|
||||
93
apps/appstore/src/components/AppGrid/AppGridItem.vue
Normal file
93
apps/appstore/src/components/AppGrid/AppGridItem.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<!--
|
||||
- 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 { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppImage from '../AppImage.vue'
|
||||
import BadgeAppDaemon from '../BadgeAppDaemon.vue'
|
||||
import BadgeAppLevel from '../BadgeAppLevel.vue'
|
||||
import BadgeAppScore from '../BadgeAppScore.vue'
|
||||
import { useUserSettingsStore } from '../../store/userSettings.ts'
|
||||
|
||||
const { app } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
}>()
|
||||
|
||||
const userSettingsStore = useUserSettingsStore()
|
||||
const route = useRoute()
|
||||
const routeToDetails = computed(() => ({
|
||||
...route,
|
||||
params: {
|
||||
...route.params,
|
||||
id: app.id,
|
||||
},
|
||||
query: userSettingsStore.getQuery(),
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="$style.appGridItem">
|
||||
<RouterLink :to="routeToDetails">
|
||||
<AppImage :app :class="$style.appGridItem__image" />
|
||||
<div :class="$style.appGridItem__content">
|
||||
<h3 :class="$style.appGridItem__name">
|
||||
{{ app.name }}
|
||||
</h3>
|
||||
<p>{{ app.summary }}</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<div :class="$style.appGridItem__badges">
|
||||
<BadgeAppScore :app />
|
||||
<BadgeAppLevel :level="app.level" />
|
||||
<BadgeAppDaemon v-if="app.app_api && app.daemon" :daemon="app.daemon" />
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appGridItem {
|
||||
background-color: var(--color-primary-element-light);
|
||||
color: var(--color-primary-element-light-text);
|
||||
border-radius: var(--border-radius-element);
|
||||
padding-block-end: var(--border-radius-element);;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: var(--default-grid-baseline);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-element-light-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.appGridItem__content {
|
||||
padding-inline: var(--border-radius-element);
|
||||
}
|
||||
|
||||
.appGridItem__image {
|
||||
aspect-ratio: 16 / 9;
|
||||
height: min-content;
|
||||
border-start-start-radius: var(--border-radius-element);
|
||||
border-start-end-radius: var(--border-radius-element);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.appGridItem__name {
|
||||
font-size: 1.2em;
|
||||
font-weight: var(--font-weight-heading, bold);
|
||||
margin-block: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline));
|
||||
}
|
||||
|
||||
.appGridItem__badges {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--default-grid-baseline);
|
||||
padding-inline: var(--border-radius-element);
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
64
apps/appstore/src/components/AppIcon.vue
Normal file
64
apps/appstore/src/components/AppIcon.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<!--
|
||||
- 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 { mdiCogOutline } from '@mdi/js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
const { app, noFallback, size = 20 } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
noFallback?: boolean
|
||||
size?: number
|
||||
}>()
|
||||
|
||||
const isSvg = computed(() => app.icon?.endsWith('.svg'))
|
||||
const svgIcon = ref<string>('')
|
||||
watch(() => app.icon, async () => {
|
||||
svgIcon.value = ''
|
||||
if (app.icon?.endsWith('.svg')) {
|
||||
const response = await fetch(app.icon)
|
||||
if (response.ok) {
|
||||
svgIcon.value = await response.text()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="$style.appIcon">
|
||||
<NcIconSvgWrapper
|
||||
v-if="svgIcon"
|
||||
:size
|
||||
:svg="svgIcon" />
|
||||
<img
|
||||
v-else-if="app.icon && !isSvg"
|
||||
:class="$style.appIcon__image"
|
||||
alt=""
|
||||
:src="app.icon"
|
||||
:height="size"
|
||||
:width="size">
|
||||
<NcIconSvgWrapper
|
||||
v-else-if="!noFallback"
|
||||
:path="mdiCogOutline"
|
||||
:size />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appIcon {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appIcon__image {
|
||||
filter: var(--invert-if-dark);
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
81
apps/appstore/src/components/AppImage.vue
Normal file
81
apps/appstore/src/components/AppImage.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<!--
|
||||
- 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 { mdiCogOutline } from '@mdi/js'
|
||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||
import PQueue from 'p-queue'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
const props = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
}>()
|
||||
|
||||
const isError = ref(false)
|
||||
const isLoading = ref(true)
|
||||
watchEffect(() => {
|
||||
if (props.app.screenshot) {
|
||||
isError.value = false
|
||||
isLoading.value = true
|
||||
queue.add(() => {
|
||||
const image = new Image()
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
image.onload = () => {
|
||||
isLoading.value = false
|
||||
resolve()
|
||||
}
|
||||
image.onerror = () => {
|
||||
isError.value = true
|
||||
isLoading.value = false
|
||||
resolve()
|
||||
}
|
||||
image.src = props.app.screenshot!
|
||||
return promise
|
||||
})
|
||||
} else {
|
||||
isLoading.value = false
|
||||
isError.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const queue = new PQueue({ concurrency: 4 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.appImage">
|
||||
<NcIconSvgWrapper
|
||||
v-if="isError || !props.app.screenshot"
|
||||
:size="80"
|
||||
:path="mdiCogOutline" />
|
||||
|
||||
<NcLoadingIcon v-else-if="isLoading" :size="80" />
|
||||
|
||||
<img
|
||||
v-else
|
||||
:class="$style.appImage__image"
|
||||
:src="props.app.screenshot"
|
||||
alt="">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appImage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.appImage__image {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
83
apps/appstore/src/components/AppLink.vue
Normal file
83
apps/appstore/src/components/AppLink.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* This component either shows a native link to the installed app or external size
|
||||
* or a router link to the appstore page of the app if not installed
|
||||
*/
|
||||
|
||||
import type { RouterLinkProps } from 'vue-router'
|
||||
import type { INavigationEntry } from '../../../../core/src/types/navigation.d.ts'
|
||||
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
href: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const knownRoutes = Object.fromEntries(loadState<INavigationEntry[]>('core', 'apps').map((app) => [app.app ?? app.id, app.href]))
|
||||
|
||||
const routerProps = ref<RouterLinkProps>()
|
||||
const linkProps = ref<Record<string, string>>()
|
||||
|
||||
watchEffect(() => {
|
||||
const match = props.href.match(/^app:(\/\/)?([^/]+)(\/.+)?$/)
|
||||
routerProps.value = undefined
|
||||
linkProps.value = undefined
|
||||
|
||||
// not an app url
|
||||
if (match === null) {
|
||||
linkProps.value = {
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const appId = match[2]!
|
||||
// Check if specific route was requested
|
||||
if (match[3]) {
|
||||
// we do no know anything about app internal path so we only allow generic app paths
|
||||
linkProps.value = {
|
||||
href: generateUrl(`/apps/${appId}${match[3]}`),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If we know any route for that app we open it
|
||||
if (appId in knownRoutes) {
|
||||
linkProps.value = {
|
||||
href: knownRoutes[appId]!,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to show the app store entry
|
||||
routerProps.value = {
|
||||
to: {
|
||||
name: 'apps-discover',
|
||||
params: {
|
||||
category: route.params?.category ?? 'discover',
|
||||
id: appId,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a v-if="linkProps" v-bind="linkProps">
|
||||
<slot />
|
||||
</a>
|
||||
<RouterLink v-else-if="routerProps" v-bind="routerProps">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
|
@ -1,484 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="app-content-inner">
|
||||
<OfficeSuiteSwitcher
|
||||
v-if="category === 'office'"
|
||||
:installed-apps="allApps"
|
||||
@suite-selected="onSuiteSelected" />
|
||||
|
||||
<div
|
||||
id="apps-list"
|
||||
class="apps-list"
|
||||
:class="{
|
||||
'apps-list--list-view': (useBundleView || useListView),
|
||||
'apps-list--store-view': useAppStoreView,
|
||||
}">
|
||||
<template v-if="useListView">
|
||||
<div v-if="showUpdateAll" class="apps-list__toolbar">
|
||||
{{ n('appstore', '%n app has an update available', '%n apps have an update available', counter) }}
|
||||
<NcButton
|
||||
v-if="showUpdateAll"
|
||||
id="app-list-update-all"
|
||||
variant="primary"
|
||||
@click="updateAll">
|
||||
{{ n('appstore', 'Update', 'Update all', counter) }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<div v-if="!showUpdateAll" class="apps-list__toolbar">
|
||||
{{ t('appstore', 'All apps are up-to-date.') }}
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="apps-list" tag="table" class="apps-list__list-container">
|
||||
<tr key="app-list-view-header">
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Icon') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Name') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Version') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Level') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
<AppItem
|
||||
v-for="app in apps"
|
||||
:key="app.id"
|
||||
:app="app"
|
||||
:category="category" />
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<table
|
||||
v-if="useBundleView"
|
||||
class="apps-list__list-container">
|
||||
<tr key="app-list-view-header">
|
||||
<th id="app-table-col-icon">
|
||||
<span class="hidden-visually">{{ t('appstore', 'Icon') }}</span>
|
||||
</th>
|
||||
<th id="app-table-col-name">
|
||||
<span class="hidden-visually">{{ t('appstore', 'Name') }}</span>
|
||||
</th>
|
||||
<th id="app-table-col-version">
|
||||
<span class="hidden-visually">{{ t('appstore', 'Version') }}</span>
|
||||
</th>
|
||||
<th id="app-table-col-level">
|
||||
<span class="hidden-visually">{{ t('appstore', 'Level') }}</span>
|
||||
</th>
|
||||
<th id="app-table-col-actions">
|
||||
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
<tbody v-for="bundle in bundles" :key="bundle.id">
|
||||
<tr>
|
||||
<th :id="`app-table-rowgroup-${bundle.id}`" colspan="5" scope="rowgroup">
|
||||
<div class="apps-list__bundle-heading">
|
||||
<span class="apps-list__bundle-header">
|
||||
{{ bundle.name }}
|
||||
</span>
|
||||
<NcButton variant="secondary" @click="toggleBundle(bundle.id)">
|
||||
{{ t('appstore', bundleToggleText(bundle.id)) }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<AppItem
|
||||
v-for="app in bundleApps(bundle.id)"
|
||||
:key="bundle.id + app.id"
|
||||
:use-bundle-view="true"
|
||||
:headers="`app-table-rowgroup-${bundle.id}`"
|
||||
:app="app"
|
||||
:category="category" />
|
||||
</tbody>
|
||||
</table>
|
||||
<ul v-if="useAppStoreView" class="apps-list__store-container">
|
||||
<AppItem
|
||||
v-for="app in apps"
|
||||
:key="app.id"
|
||||
:app="app"
|
||||
:category="category"
|
||||
:list-view="false" />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="apps-list-search" class="apps-list apps-list--list-view">
|
||||
<div class="apps-list__list-container">
|
||||
<table v-if="search !== '' && searchApps.length > 0" class="apps-list__list-container">
|
||||
<caption class="apps-list__bundle-header">
|
||||
{{ t('appstore', 'Results from other categories') }}
|
||||
</caption>
|
||||
<tr key="app-list-view-header">
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Icon') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Name') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Version') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Level') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
<AppItem
|
||||
v-for="app in searchApps"
|
||||
:key="app.id"
|
||||
:app="app"
|
||||
:category="category" />
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="search !== '' && !loading && searchApps.length === 0 && apps.length === 0" id="apps-list-empty" class="emptycontent emptycontent-search">
|
||||
<div id="app-list-empty-icon" class="icon-settings-dark" />
|
||||
<h2>{{ t('appstore', 'No apps found for your version') }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
import pLimit from 'p-limit'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import AppItem from './AppList/AppItem.vue'
|
||||
import OfficeSuiteSwitcher from './AppList/OfficeSuiteSwitcher.vue'
|
||||
import { getOfficeSuiteById, OFFICE_SUITES } from '../constants/OfficeSuites.js'
|
||||
import AppManagement from '../mixins/AppManagement.js'
|
||||
import { useAppApiStore } from '../store/app-api-store.ts'
|
||||
import { useAppsStore } from '../store/apps-store.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
export default {
|
||||
name: 'AppList',
|
||||
components: {
|
||||
AppItem,
|
||||
NcButton,
|
||||
OfficeSuiteSwitcher,
|
||||
},
|
||||
|
||||
mixins: [AppManagement],
|
||||
|
||||
props: {
|
||||
category: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const appApiStore = useAppApiStore()
|
||||
const store = useAppsStore()
|
||||
|
||||
return {
|
||||
appApiStore,
|
||||
store,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
counter() {
|
||||
return this.apps.filter((app) => app.update).length
|
||||
},
|
||||
|
||||
loading() {
|
||||
if (!this.$store.getters['appApiApps/isAppApiEnabled']) {
|
||||
return this.$store.getters.loading('list')
|
||||
}
|
||||
return this.$store.getters.loading('list') || this.appApiStore.getLoading('list')
|
||||
},
|
||||
|
||||
hasPendingUpdate() {
|
||||
return this.apps.filter((app) => app.update).length > 0
|
||||
},
|
||||
|
||||
showUpdateAll() {
|
||||
return this.hasPendingUpdate && this.useListView
|
||||
},
|
||||
|
||||
allApps() {
|
||||
const exApps = this.$store.getters.isAppApiEnabled ? this.appApiStore.getAllApps : []
|
||||
return [...this.$store.getters.getAllApps, ...exApps]
|
||||
},
|
||||
|
||||
apps() {
|
||||
// Exclude ExApps from the list if AppAPI is disabled
|
||||
const exApps = this.$store.getters.isAppApiEnabled ? this.appApiStore.getAllApps : []
|
||||
const apps = [...this.$store.getters.getAllApps, ...exApps]
|
||||
.filter((app) => app.name.toLowerCase().search(this.search.toLowerCase()) !== -1)
|
||||
.sort(function(a, b) {
|
||||
const statusA = (a.active ? 0 : 2) + (a.update ? 0 : 1)
|
||||
const statusB = (b.active ? 0 : 2) + (b.update ? 0 : 1)
|
||||
if (statusA !== statusB) {
|
||||
return statusA - statusB
|
||||
}
|
||||
return OC.Util.naturalSortCompare(a.name, b.name)
|
||||
})
|
||||
|
||||
if (this.category === 'installed') {
|
||||
return apps.filter((app) => app.installed)
|
||||
}
|
||||
if (this.category === 'enabled') {
|
||||
return apps.filter((app) => app.active && app.installed)
|
||||
}
|
||||
if (this.category === 'disabled') {
|
||||
return apps.filter((app) => !app.active && app.installed)
|
||||
}
|
||||
if (this.category === 'app-bundles') {
|
||||
return apps.filter((app) => app.bundles)
|
||||
}
|
||||
if (this.category === 'updates') {
|
||||
return apps.filter((app) => app.update)
|
||||
}
|
||||
if (this.category === 'supported') {
|
||||
// For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
|
||||
return apps.filter((app) => app.level === 300)
|
||||
}
|
||||
if (this.category === 'featured') {
|
||||
// An app level of `200` will be set for apps featured on the app store
|
||||
return apps.filter((app) => app.level === 200)
|
||||
}
|
||||
|
||||
// filter app store categories
|
||||
return apps.filter((app) => {
|
||||
return app.appstore && app.category !== undefined
|
||||
&& (app.category === this.category || app.category.indexOf(this.category) > -1)
|
||||
})
|
||||
},
|
||||
|
||||
bundles() {
|
||||
return this.$store.getters.getAppBundles.filter((bundle) => this.bundleApps(bundle.id).length > 0)
|
||||
},
|
||||
|
||||
bundleApps() {
|
||||
return function(bundle) {
|
||||
return this.$store.getters.getAllApps
|
||||
.filter((app) => {
|
||||
return app.bundleIds !== undefined && app.bundleIds.includes(bundle)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
searchApps() {
|
||||
if (this.search === '') {
|
||||
return []
|
||||
}
|
||||
const exApps = this.$store.getters.isAppApiEnabled ? this.appApiStore.getAllApps : []
|
||||
return [...this.$store.getters.getAllApps, ...exApps]
|
||||
.filter((app) => {
|
||||
if (app.name.toLowerCase().search(this.search.toLowerCase()) !== -1) {
|
||||
return (!this.apps.find((_app) => _app.id === app.id))
|
||||
}
|
||||
return false
|
||||
})
|
||||
},
|
||||
|
||||
useAppStoreView() {
|
||||
return !this.useListView && !this.useBundleView
|
||||
},
|
||||
|
||||
useListView() {
|
||||
return (this.category === 'installed' || this.category === 'enabled' || this.category === 'disabled' || this.category === 'updates' || this.category === 'featured' || this.category === 'supported')
|
||||
},
|
||||
|
||||
useBundleView() {
|
||||
return (this.category === 'app-bundles')
|
||||
},
|
||||
|
||||
allBundlesEnabled() {
|
||||
return (id) => {
|
||||
return this.bundleApps(id).filter((app) => !app.active).length === 0
|
||||
}
|
||||
},
|
||||
|
||||
bundleToggleText() {
|
||||
return (id) => {
|
||||
if (this.allBundlesEnabled(id)) {
|
||||
return t('appstore', 'Disable all')
|
||||
}
|
||||
return t('appstore', 'Download and enable all')
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
unsubscribe('nextcloud:unified-search.search', this.setSearch)
|
||||
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
|
||||
},
|
||||
|
||||
mounted() {
|
||||
subscribe('nextcloud:unified-search.search', this.setSearch)
|
||||
subscribe('nextcloud:unified-search.reset', this.resetSearch)
|
||||
},
|
||||
|
||||
methods: {
|
||||
setSearch({ query }) {
|
||||
this.search = query
|
||||
},
|
||||
|
||||
resetSearch() {
|
||||
this.search = ''
|
||||
},
|
||||
|
||||
async disableOfficeSuites(suites) {
|
||||
const disablePromises = suites.map((suite) => this.$store.dispatch('disableApp', { appId: suite.appId }).catch(() => {}))
|
||||
await Promise.all(disablePromises)
|
||||
},
|
||||
|
||||
async onSuiteSelected(suiteId) {
|
||||
logger.info('Office suite selected:', suiteId)
|
||||
|
||||
try {
|
||||
if (suiteId === null) {
|
||||
await this.disableOfficeSuites(OFFICE_SUITES)
|
||||
OC.Notification.showTemporary(t('settings', 'All office suites disabled'))
|
||||
return
|
||||
}
|
||||
|
||||
const selectedSuite = getOfficeSuiteById(suiteId)
|
||||
if (!selectedSuite) {
|
||||
logger.error('Unknown office suite selected:', suiteId)
|
||||
return
|
||||
}
|
||||
|
||||
await this.$store.dispatch('enableApp', { appId: selectedSuite.appId, groups: [] })
|
||||
OC.Notification.showTemporary(t('settings', '{name} enabled', { name: selectedSuite.name }))
|
||||
|
||||
const otherSuites = OFFICE_SUITES.filter((suite) => suite.id !== suiteId)
|
||||
await this.disableOfficeSuites(otherSuites)
|
||||
} catch (error) {
|
||||
logger.error('Error switching office suite:', error)
|
||||
if (error?.message) {
|
||||
OC.Notification.showTemporary(error.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggleBundle(id) {
|
||||
if (this.allBundlesEnabled(id)) {
|
||||
return this.disableBundle(id)
|
||||
}
|
||||
return this.enableBundle(id)
|
||||
},
|
||||
|
||||
enableBundle(id) {
|
||||
const apps = this.bundleApps(id).map((app) => app.id)
|
||||
this.$store.dispatch('enableApp', { appId: apps, groups: [] })
|
||||
.catch((error) => {
|
||||
logger.error(error)
|
||||
OC.Notification.show(error)
|
||||
})
|
||||
},
|
||||
|
||||
disableBundle(id) {
|
||||
const apps = this.bundleApps(id).map((app) => app.id)
|
||||
this.$store.dispatch('disableApp', { appId: apps, groups: [] })
|
||||
.catch((error) => {
|
||||
OC.Notification.show(error)
|
||||
})
|
||||
},
|
||||
|
||||
async updateAll() {
|
||||
const limit = pLimit(1)
|
||||
const updateTasks = this.apps
|
||||
.filter((app) => app.update)
|
||||
.map((app) => limit(() => {
|
||||
this.update(app.id)
|
||||
}))
|
||||
await Promise.all(updateTasks)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$toolbar-padding: 8px;
|
||||
$toolbar-height: 44px + $toolbar-padding * 2;
|
||||
|
||||
.apps-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
|
||||
// For transition group
|
||||
&--move {
|
||||
transition: transform 1s;
|
||||
}
|
||||
|
||||
#app-list-update-all {
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
height: $toolbar-height;
|
||||
padding: $toolbar-padding;
|
||||
// Leave room for app-navigation-toggle
|
||||
padding-inline-start: $toolbar-height;
|
||||
width: 100%;
|
||||
background-color: var(--color-main-background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&--list-view {
|
||||
margin-bottom: 100px;
|
||||
// For positioning link overlay on rows
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__list-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__store-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__bundle-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-block: 20px;
|
||||
margin-inline: 0 10px;
|
||||
}
|
||||
|
||||
&__bundle-header {
|
||||
color: var(--color-main-text);
|
||||
margin-block: 0;
|
||||
margin-inline: 50px 10px;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
#apps-list-search {
|
||||
.app-item {
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,468 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<component
|
||||
:is="listView ? 'tr' : (inline ? 'article' : 'li')"
|
||||
class="app-item"
|
||||
:class="{
|
||||
'app-item--list-view': listView,
|
||||
'app-item--store-view': !listView,
|
||||
'app-item--selected': isSelected,
|
||||
'app-item--with-sidebar': withSidebar,
|
||||
}">
|
||||
<component
|
||||
:is="dataItemTag"
|
||||
class="app-image app-image-icon"
|
||||
:headers="getDataItemHeaders(`app-table-col-icon`)">
|
||||
<div v-if="!app?.app_api && shouldDisplayDefaultIcon" class="icon-settings-dark" />
|
||||
<NcIconSvgWrapper
|
||||
v-else-if="app.app_api && shouldDisplayDefaultIcon"
|
||||
:path="mdiCogOutline"
|
||||
:size="listView ? 24 : 48"
|
||||
style="min-width: auto; min-height: auto; height: 100%;" />
|
||||
|
||||
<svg
|
||||
v-else-if="listView && app.preview && !app.app_api"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32">
|
||||
<image
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="32"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
:xlink:href="app.preview"
|
||||
class="app-icon" />
|
||||
</svg>
|
||||
|
||||
<img v-if="!listView && app.screenshot && screenshotLoaded" :src="app.screenshot" alt="">
|
||||
</component>
|
||||
<component
|
||||
:is="dataItemTag"
|
||||
class="app-name"
|
||||
:headers="getDataItemHeaders(`app-table-col-name`)">
|
||||
<router-link
|
||||
class="app-name--link"
|
||||
:to="{
|
||||
name: 'apps-details',
|
||||
params: {
|
||||
category: category,
|
||||
id: app.id,
|
||||
},
|
||||
}"
|
||||
:aria-label="t('settings', 'Show details for {appName} app', { appName: app.name })">
|
||||
{{ app.name }}
|
||||
</router-link>
|
||||
</component>
|
||||
<component
|
||||
:is="dataItemTag"
|
||||
v-if="!listView"
|
||||
class="app-summary"
|
||||
:headers="getDataItemHeaders(`app-version`)">
|
||||
{{ app.summary }}
|
||||
</component>
|
||||
<component
|
||||
:is="dataItemTag"
|
||||
v-if="listView"
|
||||
class="app-version"
|
||||
:headers="getDataItemHeaders(`app-table-col-version`)">
|
||||
<span v-if="app.version">{{ app.version }}</span>
|
||||
<span v-else-if="app.appstoreData.releases[0].version">{{ app.appstoreData.releases[0].version }}</span>
|
||||
</component>
|
||||
|
||||
<component :is="dataItemTag" :headers="getDataItemHeaders(`app-table-col-level`)" class="app-level">
|
||||
<AppLevelBadge :level="app.level" />
|
||||
<AppScore v-if="hasRating && !listView" :score="app.score" />
|
||||
</component>
|
||||
<component
|
||||
:is="dataItemTag"
|
||||
v-if="!inline"
|
||||
:headers="getDataItemHeaders(`app-table-col-actions`)"
|
||||
class="app-actions">
|
||||
<div v-if="app.error" class="warning">
|
||||
{{ app.error }}
|
||||
</div>
|
||||
<div v-if="isLoading || isInitializing" class="icon icon-loading-small" />
|
||||
<NcButton
|
||||
v-if="app.update"
|
||||
variant="primary"
|
||||
:disabled="installing || isLoading || !defaultDeployDaemonAccessible || isManualInstall"
|
||||
:title="updateButtonText"
|
||||
@click.stop="update(app.id)">
|
||||
{{ t('settings', 'Update to {update}', { update: app.update }) }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="app.canUnInstall"
|
||||
class="uninstall"
|
||||
variant="tertiary"
|
||||
:disabled="installing || isLoading"
|
||||
@click.stop="remove(app.id)">
|
||||
{{ t('settings', 'Remove') }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="app.active"
|
||||
:disabled="installing || isLoading || isInitializing || isDeploying"
|
||||
@click.stop="disable(app.id)">
|
||||
{{ disableButtonText }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="!app.active && (app.canInstall || app.isCompatible)"
|
||||
:title="enableButtonTooltip"
|
||||
:aria-label="enableButtonTooltip"
|
||||
variant="primary"
|
||||
:disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying"
|
||||
@click.stop="enableButtonAction">
|
||||
{{ enableButtonText }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-else-if="!app.active"
|
||||
:title="forceEnableButtonTooltip"
|
||||
:aria-label="forceEnableButtonTooltip"
|
||||
variant="secondary"
|
||||
:disabled="installing || isLoading || !defaultDeployDaemonAccessible"
|
||||
@click.stop="forceEnable(app.id)">
|
||||
{{ forceEnableButtonText }}
|
||||
</NcButton>
|
||||
|
||||
<DaemonSelectionDialog
|
||||
v-if="app?.app_api && showSelectDaemonModal"
|
||||
:show.sync="showSelectDaemonModal"
|
||||
:app="app" />
|
||||
</component>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mdiCogOutline } from '@mdi/js'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue'
|
||||
import SvgFilterMixin from '../SvgFilterMixin.vue'
|
||||
import AppLevelBadge from './AppLevelBadge.vue'
|
||||
import AppScore from './AppScore.vue'
|
||||
import AppManagement from '../../mixins/AppManagement.js'
|
||||
import { useAppApiStore } from '../../store/app-api-store.ts'
|
||||
import { useAppsStore } from '../../store/apps-store.js'
|
||||
|
||||
export default {
|
||||
name: 'AppItem',
|
||||
components: {
|
||||
AppLevelBadge,
|
||||
AppScore,
|
||||
NcButton,
|
||||
NcIconSvgWrapper,
|
||||
DaemonSelectionDialog,
|
||||
},
|
||||
|
||||
mixins: [AppManagement, SvgFilterMixin],
|
||||
props: {
|
||||
app: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
category: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
listView: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
useBundleView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
headers: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const store = useAppsStore()
|
||||
const appApiStore = useAppApiStore()
|
||||
|
||||
return {
|
||||
store,
|
||||
appApiStore,
|
||||
mdiCogOutline,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isSelected: false,
|
||||
scrolled: false,
|
||||
screenshotLoaded: false,
|
||||
showSelectDaemonModal: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasRating() {
|
||||
return this.app.appstoreData && this.app.appstoreData.ratingNumOverall > 5
|
||||
},
|
||||
|
||||
dataItemTag() {
|
||||
return this.listView ? 'td' : 'div'
|
||||
},
|
||||
|
||||
withSidebar() {
|
||||
return !!this.$route.params.id
|
||||
},
|
||||
|
||||
shouldDisplayDefaultIcon() {
|
||||
return (this.listView && !this.app.preview) || (!this.listView && !this.screenshotLoaded)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'$route.params.id': function(id) {
|
||||
this.isSelected = (this.app.id === id)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.isSelected = (this.app.id === this.$route.params.id)
|
||||
if (this.app.releases && this.app.screenshot) {
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
this.screenshotLoaded = true
|
||||
}
|
||||
image.src = this.app.screenshot
|
||||
}
|
||||
},
|
||||
|
||||
watchers: {
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
prefix(prefix, content) {
|
||||
return prefix + '_' + content
|
||||
},
|
||||
|
||||
getDataItemHeaders(columnName) {
|
||||
return this.useBundleView ? [this.headers, columnName].join(' ') : null
|
||||
},
|
||||
|
||||
showSelectionModal() {
|
||||
this.showSelectDaemonModal = true
|
||||
},
|
||||
|
||||
async enableButtonAction() {
|
||||
if (!this.app?.app_api) {
|
||||
this.enable(this.app.id)
|
||||
return
|
||||
}
|
||||
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">
|
||||
@use '../../../../../core/css/variables.scss' as variables;
|
||||
@use 'sass:math';
|
||||
|
||||
.app-item {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
|
||||
&--list-view {
|
||||
--app-item-padding: calc(var(--default-grid-baseline) * 2);
|
||||
--app-item-height: calc(var(--default-clickable-area) + var(--app-item-padding) * 2);
|
||||
|
||||
&.app-item--selected {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
|
||||
> * {
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--app-item-padding);
|
||||
height: var(--app-item-height);
|
||||
}
|
||||
|
||||
.app-image {
|
||||
width: var(--default-clickable-area);
|
||||
height: auto;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.app-image-icon svg,
|
||||
.app-image-icon .icon-settings-dark {
|
||||
margin-top: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: .5;
|
||||
background-size: cover;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
padding: 0 var(--app-item-padding);
|
||||
}
|
||||
|
||||
.app-name--link {
|
||||
height: var(--app-item-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Note: because of Safari bug, we cannot position link overlay relative to the table row
|
||||
// So we need to manually position it relative to the table container and cell
|
||||
// See: https://bugs.webkit.org/show_bug.cgi?id=240961
|
||||
.app-name--link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
height: var(--app-item-height);
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
gap: var(--app-item-padding);
|
||||
flex-wrap: wrap;
|
||||
justify-content: end;
|
||||
|
||||
.icon-loading-small {
|
||||
display: inline-block;
|
||||
top: 4px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* hide app version and level on narrower screens */
|
||||
@media only screen and (max-width: 900px) {
|
||||
.app-version,
|
||||
.app-level {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide actions on a small screen. Click on app opens fill-screen sidebar with the buttons */
|
||||
@media only screen and (max-width: math.div(variables.$breakpoint-mobile, 2)) {
|
||||
.app-actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--store-view {
|
||||
padding: 30px;
|
||||
|
||||
.app-image-icon .icon-settings-dark {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background-size: 45px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.app-image-icon svg {
|
||||
position: absolute;
|
||||
bottom: 43px;
|
||||
/* position halfway vertically */
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
opacity: .1;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.app-name--link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1601px) {
|
||||
width: 25%;
|
||||
|
||||
&.app-item--with-sidebar {
|
||||
width: 33%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1600px) {
|
||||
width: 25%;
|
||||
|
||||
&.app-item--with-sidebar {
|
||||
width: 33%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1400px) {
|
||||
width: 33%;
|
||||
|
||||
&.app-item--with-sidebar {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 900px) {
|
||||
width: 50%;
|
||||
|
||||
&.app-item--with-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: variables.$breakpoint-mobile) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
filter: var(--background-invert-if-bright);
|
||||
}
|
||||
|
||||
.app-image {
|
||||
position: relative;
|
||||
height: 150px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.app-version {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<span
|
||||
role="img"
|
||||
:aria-label="title"
|
||||
:title="title"
|
||||
class="app-score__wrapper">
|
||||
<NcIconSvgWrapper
|
||||
v-for="index in fullStars"
|
||||
:key="`full-star-${index}`"
|
||||
:path="mdiStar"
|
||||
inline />
|
||||
<NcIconSvgWrapper v-if="hasHalfStar" :path="mdiStarHalfFull" inline />
|
||||
<NcIconSvgWrapper
|
||||
v-for="index in emptyStars"
|
||||
:key="`empty-star-${index}`"
|
||||
:path="mdiStarOutline"
|
||||
inline />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { mdiStar, mdiStarHalfFull, mdiStarOutline } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { defineComponent } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppScore',
|
||||
components: {
|
||||
NcIconSvgWrapper,
|
||||
},
|
||||
|
||||
props: {
|
||||
score: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
return {
|
||||
mdiStar,
|
||||
mdiStarHalfFull,
|
||||
mdiStarOutline,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
title() {
|
||||
const appScore = (this.score * 5).toFixed(1)
|
||||
return t('settings', 'Community rating: {score}/5', { score: appScore })
|
||||
},
|
||||
|
||||
fullStars() {
|
||||
return Math.floor(this.score * 5 + 0.25)
|
||||
},
|
||||
|
||||
emptyStars() {
|
||||
return Math.min(Math.floor((1 - this.score) * 5 + 0.25), 5 - this.fullStars)
|
||||
},
|
||||
|
||||
hasHalfStar() {
|
||||
return (this.fullStars + this.emptyStars) < 5
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-score__wrapper {
|
||||
display: inline-flex;
|
||||
color: var(--color-favorite, #a08b00);
|
||||
|
||||
> * {
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { shallowRef } from 'vue'
|
||||
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
|
||||
import IconCheckCircle from 'vue-material-design-icons/CheckCircle.vue'
|
||||
import { OFFICE_SUITES } from '../../constants/OfficeSuites.ts'
|
||||
|
||||
const { installedApps = [] } = defineProps<{
|
||||
installedApps?: { id: string, active: boolean }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'suite-selected': [suiteId: string | null]
|
||||
}>()
|
||||
|
||||
const isAllInOne = loadState('settings', 'isAllInOne', false)
|
||||
const selectedSuite = shallowRef<string | null>(getInitialSuite())
|
||||
|
||||
function getInitialSuite() {
|
||||
for (const suite of OFFICE_SUITES) {
|
||||
const app = installedApps.find((a) => a.id === suite.appId)
|
||||
if (app && app.active) {
|
||||
return suite.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function selectSuite(suiteId: string) {
|
||||
if (selectedSuite.value === suiteId) {
|
||||
// already selected — keep selection; use the disable button to clear
|
||||
return
|
||||
}
|
||||
selectedSuite.value = suiteId
|
||||
emit('suite-selected', suiteId)
|
||||
}
|
||||
|
||||
function disableSuites() {
|
||||
if (!selectedSuite.value) {
|
||||
return
|
||||
}
|
||||
selectedSuite.value = null
|
||||
emit('suite-selected', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="office-suite-switcher">
|
||||
<div v-if="isAllInOne" class="office-suite-switcher__aio-message">
|
||||
<p>{{ t('settings', 'Office suite switching is managed through the Nextcloud All-in-One interface.') }}</p>
|
||||
<p>{{ t('settings', 'Please use the AIO interface to switch between office suites.') }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p>{{ t('settings', 'Select your preferred office suite. Please note that installing requires manual server setup.') }}</p>
|
||||
<div class="office-suite-cards">
|
||||
<div
|
||||
v-for="suite in OFFICE_SUITES"
|
||||
:key="suite.id"
|
||||
class="office-suite-card"
|
||||
:class="{
|
||||
'office-suite-card--primary': suite.isPrimary,
|
||||
'office-suite-card--selected': selectedSuite === suite.id,
|
||||
}"
|
||||
@click="selectSuite(suite.id)">
|
||||
<div class="office-suite-card__header">
|
||||
<h3 class="office-suite-card__title">
|
||||
{{ suite.name }}
|
||||
<span v-if="selectedSuite === suite.id">({{ t('settings', 'installed') }})</span>
|
||||
</h3>
|
||||
<IconCheckCircle v-if="selectedSuite === suite.id" class="office-suite-card__check" :size="24" />
|
||||
</div>
|
||||
<ul class="office-suite-card__features">
|
||||
<li v-for="(feature, index) in suite.features" :key="index">
|
||||
{{ t('settings', feature) }}
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
:href="suite.learnMoreUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="office-suite-card__link"
|
||||
@click.stop>
|
||||
{{ t('settings', 'Learn more') }}
|
||||
<IconArrowRight :size="20" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="office-suite-actions">
|
||||
<button
|
||||
class="office-suite-disable-button"
|
||||
:disabled="!selectedSuite"
|
||||
@click="disableSuites">
|
||||
{{ t('settings', 'Disable office suites') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.office-suite-switcher {
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&__aio-message {
|
||||
background-color: var(--color-background-dark);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
|
||||
&:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.office-suite-cards {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.office-suite-card {
|
||||
flex: 1;
|
||||
background-color: var(--color-main-background);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: linear-gradient(135deg, var(--color-primary-element-light) 0%, var(--color-main-background) 100%);
|
||||
color: var(--color-main-text);
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.office-suite-card--primary &__check {
|
||||
color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
&__features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 20px 0;
|
||||
flex-grow: 1;
|
||||
|
||||
li {
|
||||
padding: 4px 0;
|
||||
padding-inline-start: 20px;
|
||||
position: relative;
|
||||
line-height: 1.5;
|
||||
|
||||
&::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-main-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-top: auto;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.office-suite-card--selected &__link {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
|
||||
.office-suite-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.office-suite-disable-button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-small);
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-main-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.office-suite-disable-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.office-suite-disable-button:hover:not(:disabled) {
|
||||
border-color: var(--color-primary-element);
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.office-suite-cards {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<a v-if="linkProps" v-bind="linkProps">
|
||||
<slot />
|
||||
</a>
|
||||
<RouterLink v-else-if="routerProps" v-bind="routerProps">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { RouterLinkProps } from 'vue-router/types/router.js'
|
||||
import type { INavigationEntry } from '../../../../../core/src/types/navigation.d.ts'
|
||||
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { defineComponent } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const apps = loadState<INavigationEntry[]>('core', 'apps')
|
||||
const knownRoutes = Object.fromEntries(apps.map((app) => [app.app ?? app.id, app.href]))
|
||||
|
||||
/**
|
||||
* This component either shows a native link to the installed app or external size - or a router link to the appstore page of the app if not installed
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'AppLink',
|
||||
|
||||
components: { RouterLink },
|
||||
|
||||
props: {
|
||||
href: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
routerProps: undefined as RouterLinkProps | undefined,
|
||||
linkProps: undefined as Record<string, string> | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
href: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
const match = this.href.match(/^app:\/\/([^/]+)(\/.+)?$/)
|
||||
this.routerProps = undefined
|
||||
this.linkProps = undefined
|
||||
|
||||
// not an app url
|
||||
if (match === null) {
|
||||
this.linkProps = {
|
||||
href: this.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const appId = match[1]
|
||||
// Check if specific route was requested
|
||||
if (match[2]) {
|
||||
// we do no know anything about app internal path so we only allow generic app paths
|
||||
this.linkProps = {
|
||||
href: generateUrl(`/apps/${appId}${match[2]}`),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If we know any route for that app we open it
|
||||
if (appId in knownRoutes) {
|
||||
this.linkProps = {
|
||||
href: knownRoutes[appId],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to show the app store entry
|
||||
this.routerProps = {
|
||||
to: {
|
||||
name: 'apps-details',
|
||||
params: {
|
||||
category: this.$route.params?.category ?? 'discover',
|
||||
id: appId,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<div class="app-discover">
|
||||
<NcEmptyContent
|
||||
v-if="hasError"
|
||||
:name="t('settings', 'Nothing to show')"
|
||||
:description="t('settings', 'Could not load section content from app store.')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiEyeOffOutline" :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<NcEmptyContent
|
||||
v-else-if="elements.length === 0"
|
||||
:name="t('settings', 'Loading')"
|
||||
:description="t('settings', 'Fetching the latest news…')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<template v-else>
|
||||
<component
|
||||
:is="getComponent(entry.type)"
|
||||
v-for="entry, index in elements"
|
||||
:key="entry.id ?? index"
|
||||
v-bind="entry" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OCSResponse } from '@nextcloud/typings/ocs'
|
||||
import type { IAppDiscoverElements } from '../../constants/AppDiscoverTypes.ts'
|
||||
|
||||
import { mdiEyeOffOutline } from '@mdi/js'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import { filterElements, parseApiResponse } from '../../utils/appDiscoverParser.ts'
|
||||
import logger from '../../utils/logger.ts'
|
||||
|
||||
const PostType = defineAsyncComponent(() => import('./PostType.vue'))
|
||||
const CarouselType = defineAsyncComponent(() => import('./CarouselType.vue'))
|
||||
const ShowcaseType = defineAsyncComponent(() => import('./ShowcaseType.vue'))
|
||||
|
||||
const hasError = ref(false)
|
||||
const elements = ref<IAppDiscoverElements[]>([])
|
||||
|
||||
/**
|
||||
* Shuffle using the Fisher-Yates algorithm
|
||||
*
|
||||
* @param array The array to shuffle (in place)
|
||||
*/
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]]
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the app discover section information
|
||||
*/
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
const response = await axios.get<OCSResponse<Record<string, unknown>[]>>(generateOcsUrl('/apps/appstore/api/v1/discover'))
|
||||
const { data } = response.data.ocs
|
||||
if (data.length === 0) {
|
||||
logger.info('No app discover elements available (empty response)')
|
||||
hasError.value = true
|
||||
return
|
||||
}
|
||||
// Parse data to ensure dates are useable and then filter out expired or future elements
|
||||
const parsedElements = data.map(parseApiResponse).filter(filterElements)
|
||||
// Shuffle elements to make it looks more interesting
|
||||
const shuffledElements = shuffleArray(parsedElements)
|
||||
// Sort pinned elements first
|
||||
shuffledElements.sort((a, b) => (a.order ?? Infinity) < (b.order ?? Infinity) ? -1 : 1)
|
||||
// Set the elements to the UI
|
||||
elements.value = shuffledElements
|
||||
} catch (error) {
|
||||
hasError.value = true
|
||||
logger.error(error as Error)
|
||||
showError(t('settings', 'Could not load app discover section'))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
*/
|
||||
function getComponent(type) {
|
||||
if (type === 'post') {
|
||||
return PostType
|
||||
} else if (type === 'carousel') {
|
||||
return CarouselType
|
||||
} else if (type === 'showcase') {
|
||||
return ShowcaseType
|
||||
}
|
||||
return defineComponent({
|
||||
mounted: () => logger.error('Unknown component requested ', type),
|
||||
render: (h) => h('div', t('settings', 'Could not render element')),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-discover {
|
||||
max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */
|
||||
margin-inline: auto;
|
||||
padding-inline: 54px;
|
||||
/* Padding required to make last element not bound to the bottom */
|
||||
padding-block-end: var(--default-clickable-area, 44px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--default-clickable-area, 44px);
|
||||
}
|
||||
</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>
|
||||
<Markdown
|
||||
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 Markdown from '../Markdown.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>
|
||||
77
apps/appstore/src/components/AppTable/AppTable.vue
Normal file
77
apps/appstore/src/components/AppTable/AppTable.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<!--
|
||||
- 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 { useElementSize } from '@vueuse/core'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import AppTableRow from './AppTableRow.vue'
|
||||
|
||||
defineProps<{
|
||||
apps: (IAppstoreApp | IAppstoreExApp)[]
|
||||
}>()
|
||||
|
||||
const tableElement = useTemplateRef('table')
|
||||
const { width: tableWidth } = useElementSize(tableElement)
|
||||
|
||||
const isNarrow = computed(() => tableWidth.value < 768)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table ref="table" :class="[$style.appTable, { [$style.appTable_narrow]: isNarrow }]">
|
||||
<colgroup>
|
||||
<col :class="$style.appTable__colName">
|
||||
<col :class="$style.appTable__colVersion">
|
||||
<col v-if="!isNarrow" :class="$style.appTable__colSupport">
|
||||
<col :class="$style.appTable__colActions">
|
||||
</colgroup>
|
||||
<thead hidden>
|
||||
<tr>
|
||||
<th>{{ t('appstore', 'App name') }}</th>
|
||||
<th>{{ t('appstore', 'Version') }}</th>
|
||||
<th v-if="!isNarrow">
|
||||
{{ t('appstore', 'Support level') }}
|
||||
</th>
|
||||
<th>{{ t('appstore', 'Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<AppTableRow
|
||||
v-for="app in apps"
|
||||
:key="app.id"
|
||||
:app
|
||||
:isNarrow />
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appTable {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appTable__colName {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.appTable_narrow .appTable__colName {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.appTable__colSupport {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.appTable__colActions {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.appTable_narrow .appTable__colActions {
|
||||
width: calc(3 * var(--default-grid-baseline) + 2 * var(--default-clickable-area));
|
||||
}
|
||||
</style>
|
||||
130
apps/appstore/src/components/AppTable/AppTableRow.vue
Normal file
130
apps/appstore/src/components/AppTable/AppTableRow.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<!--
|
||||
- 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 { 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 AppIcon from '../AppIcon.vue'
|
||||
import BadgeAppDaemon from '../BadgeAppDaemon.vue'
|
||||
import BadgeAppLevel from '../BadgeAppLevel.vue'
|
||||
import { useActions } from '../../composables/useActions.ts'
|
||||
|
||||
const { app, isNarrow } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
isNarrow?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const detailsRoute = computed(() => ({
|
||||
...route,
|
||||
params: {
|
||||
...route.params,
|
||||
id: app.id,
|
||||
},
|
||||
query: {
|
||||
...route.query,
|
||||
},
|
||||
}))
|
||||
|
||||
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>
|
||||
<tr :class="$style.appTableRow">
|
||||
<td :class="$style.appTableRow__nameCell">
|
||||
<NcButton
|
||||
alignment="start"
|
||||
:title="t('appstore', 'Show details')"
|
||||
:to="detailsRoute"
|
||||
variant="tertiary-no-background"
|
||||
wide>
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="app.loading" :size="24" />
|
||||
<AppIcon v-else :app :size="24" />
|
||||
</template>
|
||||
{{ app.name }}
|
||||
<span v-if="app.loading" class="hidden-visually">({{ t('appstore', 'is loading…') }})</span>
|
||||
<span class="hidden-visually">({{ t('appstore', 'Show details') }})</span>
|
||||
</NcButton>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="$style.appTableRow__versionCell">{{ app.version }}</span>
|
||||
</td>
|
||||
<td v-if="!isNarrow">
|
||||
<div :class="$style.appTableRow__levelCell">
|
||||
<BadgeAppLevel v-if="app.level" :level="app.level" />
|
||||
<BadgeAppDaemon v-if="'daemon' in app && app.daemon" :daemon="app.daemon" />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.appTableRow__actionsCell">
|
||||
<AppActions
|
||||
:class="$style.appTableRow__actionsCellActions"
|
||||
:app
|
||||
:actions
|
||||
:iconOnly="isNarrow" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appTableRow {
|
||||
height: calc(var(--default-clickable-area) + var(--default-grid-baseline));
|
||||
}
|
||||
|
||||
.appTableRow td {
|
||||
padding-block: var(--default-grid-baseline);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.appTableRow__nameCell {
|
||||
/* Padding is needed to have proper focus-visible */
|
||||
padding-inline: var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
.appTableRow__levelCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--default-grid-baseline)
|
||||
}
|
||||
|
||||
.appTableRow__versionCell {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.appTableRow__actionsCell {
|
||||
display: flex;
|
||||
gap: var(--default-grid-baseline);
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.appTableRow__actionsCellActions {
|
||||
width: 100%;
|
||||
justify-content: end;
|
||||
}
|
||||
</style>
|
||||
136
apps/appstore/src/components/AppToolbar.vue
Normal file
136
apps/appstore/src/components/AppToolbar.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiFilterVariant, mdiSizeL, mdiSizeM, mdiSizeS, mdiViewGrid } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcActionButtonGroup from '@nextcloud/vue/components/NcActionButtonGroup'
|
||||
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import { useUserSettingsStore } from '../store/userSettings.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userSettingsStore = useUserSettingsStore()
|
||||
|
||||
watch(() => userSettingsStore.isGridView, (enabled: boolean) => {
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
grid: enabled ? null : undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => userSettingsStore.defaultGridSize, (newSize) => {
|
||||
if (userSettingsStore.isGridView) {
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
grid: newSize || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => userSettingsStore.showIncompatible, (showIncompatible) => {
|
||||
if (showIncompatible) {
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
compatible: undefined,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
compatible: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.appToolbar">
|
||||
<NcActions :class="$style.appToolbar__filterButton" :aria-label="t('appstore', 'Filter view')" forceMenu>
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFilterVariant" />
|
||||
</template>
|
||||
<NcActionButtonGroup v-if="userSettingsStore.isGridView" :name="t('appstore', 'Grid size')">
|
||||
<NcActionButton
|
||||
:aria-label="t('appstore', 'Small grid size')"
|
||||
:modelValue="userSettingsStore.defaultGridSize === ''"
|
||||
type="radio"
|
||||
value=""
|
||||
@update:modelValue="userSettingsStore.defaultGridSize = ''">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiSizeS" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('appstore', 'Medium grid size')"
|
||||
:modelValue="userSettingsStore.defaultGridSize === 'm'"
|
||||
type="radio"
|
||||
value="m"
|
||||
@update:modelValue="userSettingsStore.defaultGridSize = 'm'">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiSizeM" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('appstore', 'Large grid size')"
|
||||
:modelValue="userSettingsStore.defaultGridSize === 'l'"
|
||||
type="radio"
|
||||
value="l"
|
||||
@update:modelValue="userSettingsStore.defaultGridSize = 'l'">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiSizeL" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
</NcActionButtonGroup>
|
||||
|
||||
<NcActionCheckbox v-model="userSettingsStore.showIncompatible">
|
||||
{{ t('appstore', 'Show incompatible') }}
|
||||
</NcActionCheckbox>
|
||||
</NcActions>
|
||||
|
||||
<NcButton
|
||||
v-model:pressed="userSettingsStore.isGridView"
|
||||
:aria-label="t('appstore', 'Grid view')"
|
||||
variant="tertiary">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiViewGrid" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appToolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: calc(2 * var(--default-grid-baseline));
|
||||
position: absolute;
|
||||
inset-block-start: var(--app-navigation-padding);
|
||||
inset-inline-end: var(--app-sidebar-padding);
|
||||
|
||||
z-index: 999;
|
||||
|
||||
button:not([aria-pressed="true"]):not(:hover) {
|
||||
background-color: var(--color-main-background) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { showConfirmation } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { ref, watch } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import OfficeSuiteSwitcherItem from './OfficeSuiteSwitcherItem.vue'
|
||||
import { OFFICE_SUITES } from '../../service/OfficeSuites.ts'
|
||||
import { useAppsStore } from '../../store/apps.ts'
|
||||
import { canDisable, needForceEnable } from '../../utils/appStatus.ts'
|
||||
|
||||
const store = useAppsStore()
|
||||
const isAllInOne = loadState('appstore', 'isAllInOne', false)
|
||||
|
||||
const isProcessing = ref(false)
|
||||
const selectedSuiteId = ref<string | null>(getInitialSuite())
|
||||
watch(selectedSuiteId, onSuiteChanged)
|
||||
|
||||
/**
|
||||
* Get the initially selected office suite based on the installed apps
|
||||
*/
|
||||
function getInitialSuite() {
|
||||
for (const suite of OFFICE_SUITES) {
|
||||
const app = store.apps.find((a) => a.id === suite.appId && a.installed)
|
||||
if (app && app.active) {
|
||||
return suite.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all office suites
|
||||
*/
|
||||
function disableSuites() {
|
||||
selectedSuiteId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a specific office suite
|
||||
*
|
||||
* @param suite - The suite to disable
|
||||
*/
|
||||
async function disableSuite(suite: typeof OFFICE_SUITES[number]) {
|
||||
const app = store.getAppById(suite.appId)
|
||||
if (!app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (canDisable(app)) {
|
||||
await store.disableApp(suite.appId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to handle office suite changes. Enables the selected suite and disables others.
|
||||
*
|
||||
* @param newSuiteId - The new selected suite ID
|
||||
* @param oldSuiteId - The previously selected suite ID
|
||||
*/
|
||||
async function onSuiteChanged(newSuiteId: string | null, oldSuiteId: string | null) {
|
||||
if (isProcessing.value || newSuiteId === oldSuiteId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessing.value = true
|
||||
const suite = OFFICE_SUITES.find((s) => s.id === newSuiteId)
|
||||
if (!suite) {
|
||||
// No suite selected, disable all suites
|
||||
for (const s of OFFICE_SUITES) {
|
||||
await disableSuite(s)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const app = store.getAppById(suite.appId)!
|
||||
if (needForceEnable(app)) {
|
||||
const result = await showConfirmation({
|
||||
name: t('appstore', 'Force enable {suite}?', { suite: suite.name }),
|
||||
text: t('appstore', 'Enabling {suite} requires force enabling the app. This may cause issues with your Nextcloud instance. Are you sure you want to proceed?', { suite: suite.name }),
|
||||
labelConfirm: t('appstore', 'Force enable'),
|
||||
labelReject: t('appstore', 'Cancel'),
|
||||
severity: 'warning',
|
||||
})
|
||||
|
||||
if (result) {
|
||||
await store.forceEnableApp(suite.appId)
|
||||
} else {
|
||||
// Revert selection
|
||||
selectedSuiteId.value = oldSuiteId
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Enable the selected suite and disable others
|
||||
for (const s of OFFICE_SUITES) {
|
||||
if (s.id === newSuiteId) {
|
||||
await store.enableApp(s.appId)
|
||||
} else {
|
||||
await disableSuite(s)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcNoteCard v-if="isAllInOne" type="info">
|
||||
<p>{{ t('appstore', 'Office suite switching is managed through the Nextcloud All-in-One interface.') }}</p>
|
||||
<p>{{ t('appstore', 'Please use the AIO interface to switch between office suites.') }}</p>
|
||||
</NcNoteCard>
|
||||
|
||||
<section v-else :class="$style.officeSuiteSwitcher">
|
||||
<h3 :class="$style.officeSuiteSwitcher__title">
|
||||
{{ t('appstore', 'Select your preferred office suite.') }}
|
||||
</h3>
|
||||
<p>{{ t('appstore', 'Please note that installing requires manual server setup.') }}</p>
|
||||
<fieldset :class="$style.officeSuiteSwitcher__cards">
|
||||
<OfficeSuiteSwitcherItem
|
||||
v-for="suite in OFFICE_SUITES"
|
||||
:key="suite.id"
|
||||
v-model:selected="selectedSuiteId"
|
||||
:class="$style.officeSuiteSwitcher__cardsItem"
|
||||
:suite="suite"
|
||||
:loading="isProcessing" />
|
||||
</fieldset>
|
||||
<div :class="$style.officeSuiteSwitcher__actions">
|
||||
<NcButton :disabled="!selectedSuiteId" @click="disableSuites">
|
||||
{{ t('appstore', 'Disable office suites') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.officeSuiteSwitcher {
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h3 {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
|
||||
&:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__cards {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__cardsItem {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__disableButton {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-small);
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-main-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__disableButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__disableButton:hover:not(:disabled) {
|
||||
border-color: var(--color-primary-element);
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.officeSuiteSwitcher__cards {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OFFICE_SUITES } from '../../service/OfficeSuites.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, useId } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import { useAppsStore } from '../../store/apps.ts'
|
||||
import { canInstall } from '../../utils/appStatus.ts'
|
||||
|
||||
const selectedSuiteId = defineModel<string | null>('selected')
|
||||
|
||||
const { suite } = defineProps<{
|
||||
suite: typeof OFFICE_SUITES[number]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const headerId = useId()
|
||||
const store = useAppsStore()
|
||||
|
||||
const app = computed(() => store.getAppById(suite.appId))
|
||||
const isInstalled = computed(() => !!app.value?.installed)
|
||||
const cannotInstall = computed(() => !app.value || (!isInstalled.value && !canInstall(app.value!)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[$style.officeSuiteSwitcherItem, {
|
||||
[$style.officeSuiteSwitcherItem_selected]: selectedSuiteId === suite.id,
|
||||
}]"
|
||||
@click="selectedSuiteId = suite.id">
|
||||
<div :class="$style.officeSuiteSwitcherItem__header">
|
||||
<h3 :id="headerId" :class="$style.officeSuiteSwitcherItem__title">
|
||||
{{ suite.name }}
|
||||
<span v-if="isInstalled">({{ t('appstore', 'installed') }})</span>
|
||||
</h3>
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="selectedSuiteId"
|
||||
:aria-labelledby="headerId"
|
||||
:disabled="cannotInstall"
|
||||
:loading="loading"
|
||||
type="radio"
|
||||
name="office-suite"
|
||||
:value="suite.id"
|
||||
@click.stop />
|
||||
</div>
|
||||
<ul :aria-label="t('appstore', 'Features')" :class="$style.officeSuiteSwitcherItem__features">
|
||||
<li v-for="(feature, index) in suite.features" :key="index">
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
<NcButton :href="suite.learnMoreUrl" @click.stop>
|
||||
{{ t('appstore', 'Learn more') }}↗
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.officeSuiteSwitcherItem {
|
||||
flex: 1;
|
||||
background-color: var(--color-main-background);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem_selected {
|
||||
background: linear-gradient(135deg, var(--color-primary-element-light) 0%, var(--color-main-background) 100%);
|
||||
color: var(--color-main-text);
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem__title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem__features {
|
||||
list-style: disc;
|
||||
padding: 0;
|
||||
margin: 0 0 1em 0;
|
||||
flex-grow: 1;
|
||||
|
||||
li {
|
||||
padding-block: var(--default-grid-baseline) 0;
|
||||
padding-inline-start: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-main-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-top: auto;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem_selected .officeSuiteSwitcherItem__link {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,30 +3,11 @@
|
|||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
v-if="app?.daemon"
|
||||
id="daemon"
|
||||
:name="t('settings', 'Daemon')"
|
||||
:order="3">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFileChart" :size="24" />
|
||||
</template>
|
||||
<div class="daemon">
|
||||
<h4>{{ t('settings', 'Deploy Daemon') }}</h4>
|
||||
<p><b>{{ t('settings', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
|
||||
<p><b>{{ t('settings', 'Name') }}</b>: {{ app?.daemon.name }}</p>
|
||||
<p><b>{{ t('settings', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
|
||||
<p><b>{{ t('settings', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
|
||||
<p><b>{{ t('settings', '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'
|
||||
import { ref } from 'vue'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
|
@ -38,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>
|
||||
|
|
@ -7,17 +7,17 @@
|
|||
<NcDialog
|
||||
:open="show"
|
||||
size="normal"
|
||||
:name="t('settings', 'Advanced deploy options')"
|
||||
:name="t('appstore', 'Advanced deploy options')"
|
||||
@update:open="$emit('update:show', $event)">
|
||||
<div class="modal__content">
|
||||
<p class="deploy-option__hint">
|
||||
{{ configuredDeployOptions === null ? t('settings', 'Edit ExApp deploy options before installation') : t('settings', 'Configured ExApp deploy options. Can be set only during installation') }}.
|
||||
{{ configuredDeployOptions === null ? t('appstore', 'Edit ExApp deploy options before installation') : t('appstore', 'Configured ExApp deploy options. Can be set only during installation') }}.
|
||||
<a v-if="deployOptionsDocsUrl" :href="deployOptionsDocsUrl">
|
||||
{{ t('settings', 'Learn more') }}
|
||||
{{ t('appstore', 'Learn more') }}
|
||||
</a>
|
||||
</p>
|
||||
<h3 v-if="environmentVariables.length > 0 || (configuredDeployOptions !== null && configuredDeployOptions.environment_variables.length > 0)">
|
||||
{{ t('settings', 'Environment variables') }}
|
||||
{{ t('appstore', 'Environment variables') }}
|
||||
</h3>
|
||||
<template v-if="configuredDeployOptions === null">
|
||||
<div
|
||||
|
|
@ -34,40 +34,40 @@
|
|||
v-else-if="Object.keys(configuredDeployOptions).length > 0"
|
||||
class="envs">
|
||||
<legend class="deploy-option__hint">
|
||||
{{ t('settings', 'ExApp container environment variables') }}
|
||||
{{ t('appstore', 'ExApp container environment variables') }}
|
||||
</legend>
|
||||
<NcTextField
|
||||
v-for="(value, key) in configuredDeployOptions.environment_variables"
|
||||
:key="key"
|
||||
:key
|
||||
:label="value.displayName ?? key"
|
||||
:helper-text="value.description"
|
||||
:model-value="value.value"
|
||||
:helperText="value.description"
|
||||
:modelValue="value.value"
|
||||
readonly />
|
||||
</fieldset>
|
||||
<template v-else>
|
||||
<p class="deploy-option__hint">
|
||||
{{ t('settings', 'No environment variables defined') }}
|
||||
{{ t('appstore', 'No environment variables defined') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<h3>{{ t('settings', 'Mounts') }}</h3>
|
||||
<h3>{{ t('appstore', 'Mounts') }}</h3>
|
||||
<template v-if="configuredDeployOptions === null">
|
||||
<p class="deploy-option__hint">
|
||||
{{ t('settings', 'Define host folder mounts to bind to the ExApp container') }}
|
||||
{{ t('appstore', 'Define host folder mounts to bind to the ExApp container') }}
|
||||
</p>
|
||||
<NcNoteCard type="info" :text="t('settings', 'Must exist on the Deploy daemon host prior to installing the ExApp')" />
|
||||
<NcNoteCard type="info" :text="t('appstore', 'Must exist on the Deploy daemon host prior to installing the ExApp')" />
|
||||
<div
|
||||
v-for="mount in deployOptions.mounts"
|
||||
:key="mount.hostPath"
|
||||
class="deploy-option"
|
||||
style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
|
||||
<NcTextField v-model="mount.hostPath" :label="t('settings', 'Host path')" />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('settings', 'Container path')" />
|
||||
<NcTextField v-model="mount.hostPath" :label="t('appstore', 'Host path')" />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('appstore', 'Container path')" />
|
||||
<NcCheckboxRadioSwitch v-model="mount.readonly">
|
||||
{{ t('settings', 'Read-only') }}
|
||||
{{ t('appstore', 'Read-only') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcButton
|
||||
:aria-label="t('settings', 'Remove mount')"
|
||||
:aria-label="t('appstore', 'Remove mount')"
|
||||
style="margin-top: 6px;"
|
||||
@click="removeMount(mount)">
|
||||
<template #icon>
|
||||
|
|
@ -77,73 +77,73 @@
|
|||
</div>
|
||||
<div v-if="addingMount" class="deploy-option">
|
||||
<h4>
|
||||
{{ t('settings', 'New mount') }}
|
||||
{{ t('appstore', 'New mount') }}
|
||||
</h4>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
|
||||
<NcTextField
|
||||
ref="newMountHostPath"
|
||||
v-model="newMountPoint.hostPath"
|
||||
:label="t('settings', 'Host path')"
|
||||
:aria-label="t('settings', 'Enter path to host folder')" />
|
||||
:label="t('appstore', 'Host path')"
|
||||
:aria-label="t('appstore', 'Enter path to host folder')" />
|
||||
<NcTextField
|
||||
v-model="newMountPoint.containerPath"
|
||||
:label="t('settings', 'Container path')"
|
||||
:aria-label="t('settings', 'Enter path to container folder')" />
|
||||
:label="t('appstore', 'Container path')"
|
||||
:aria-label="t('appstore', 'Enter path to container folder')" />
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="newMountPoint.readonly"
|
||||
:aria-label="t('settings', 'Toggle read-only mode')">
|
||||
{{ t('settings', 'Read-only') }}
|
||||
:aria-label="t('appstore', 'Toggle read-only mode')">
|
||||
{{ t('appstore', 'Read-only') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; margin-top: 4px;">
|
||||
<NcButton
|
||||
:aria-label="t('settings', 'Confirm adding new mount')"
|
||||
:aria-label="t('appstore', 'Confirm adding new mount')"
|
||||
@click="addMountPoint">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiCheck" />
|
||||
</template>
|
||||
{{ t('settings', 'Confirm') }}
|
||||
{{ t('appstore', 'Confirm') }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:aria-label="t('settings', 'Cancel adding mount')"
|
||||
:aria-label="t('appstore', 'Cancel adding mount')"
|
||||
style="margin-left: 4px;"
|
||||
@click="cancelAddMountPoint">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiClose" />
|
||||
</template>
|
||||
{{ t('settings', 'Cancel') }}
|
||||
{{ t('appstore', 'Cancel') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<NcButton
|
||||
v-if="!addingMount"
|
||||
:aria-label="t('settings', 'Add mount')"
|
||||
:aria-label="t('appstore', 'Add mount')"
|
||||
style="margin-top: 5px;"
|
||||
@click="startAddingMount">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiPlus" />
|
||||
</template>
|
||||
{{ t('settings', 'Add mount') }}
|
||||
{{ t('appstore', 'Add mount') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
<template v-else-if="configuredDeployOptions.mounts.length > 0">
|
||||
<p class="deploy-option__hint">
|
||||
{{ t('settings', 'ExApp container mounts') }}
|
||||
{{ t('appstore', 'ExApp container mounts') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="mount in configuredDeployOptions.mounts"
|
||||
:key="mount.hostPath"
|
||||
class="deploy-option"
|
||||
style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
|
||||
<NcTextField v-model="mount.hostPath" :label="t('settings', 'Host path')" readonly />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('settings', 'Container path')" readonly />
|
||||
<NcTextField v-model="mount.hostPath" :label="t('appstore', 'Host path')" readonly />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('appstore', 'Container path')" readonly />
|
||||
<NcCheckboxRadioSwitch v-model="mount.readonly" disabled>
|
||||
{{ t('settings', 'Read-only') }}
|
||||
{{ t('appstore', 'Read-only') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="deploy-option__hint">
|
||||
{{ t('settings', 'No mounts defined') }}
|
||||
{{ t('appstore', 'No mounts defined') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -165,6 +165,7 @@ import { mdiCheck, mdiClose, mdiDeleteOutline, mdiPlus } from '@mdi/js'
|
|||
import axios from '@nextcloud/axios'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { computed, ref } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
|
|
@ -201,6 +202,8 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
emits: ['update:show'],
|
||||
|
||||
setup(props) {
|
||||
// for AppManagement mixin
|
||||
const store = useAppsStore()
|
||||
|
|
@ -222,6 +225,8 @@ export default {
|
|||
})
|
||||
|
||||
return {
|
||||
t,
|
||||
|
||||
environmentVariables,
|
||||
deployOptions,
|
||||
store,
|
||||
|
|
@ -244,7 +249,7 @@ export default {
|
|||
|
||||
addingPortBinding: false,
|
||||
configuredDeployOptions: null,
|
||||
deployOptionsDocsUrl: loadState('settings', 'deployOptionsDocsUrl', null),
|
||||
deployOptionsDocsUrl: loadState('appstore', 'deployOptionsDocsUrl', null),
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -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 Markdown from '../Markdown.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 BadgeAppDaemon from '../BadgeAppDaemon.vue'
|
||||
import BadgeAppLevel from '../BadgeAppLevel.vue'
|
||||
import BadgeAppScore from '../BadgeAppScore.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">
|
||||
<BadgeAppLevel :level="app.level" />
|
||||
<BadgeAppDaemon v-if="app.app_api && app.daemon" :daemon="app.daemon" />
|
||||
<BadgeAppScore :app />
|
||||
</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>
|
||||
|
|
@ -2,18 +2,9 @@
|
|||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<span
|
||||
v-if="daemon"
|
||||
class="app-daemon-badge"
|
||||
:title="daemon.name">
|
||||
<NcIconSvgWrapper :path="mdiFileChart" :size="20" inline />
|
||||
{{ daemon.display_name }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IDeployDaemon } from '../../app-types.ts'
|
||||
import type { IDeployDaemon } from '../apps.d.ts'
|
||||
|
||||
import { mdiFileChart } from '@mdi/js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
|
@ -23,8 +14,18 @@ defineProps<{
|
|||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-daemon-badge {
|
||||
<template>
|
||||
<span
|
||||
v-if="daemon"
|
||||
:class="$style.appDaemonBadge"
|
||||
:title="daemon.name">
|
||||
<NcIconSvgWrapper :path="mdiFileChart" :size="20" inline />
|
||||
{{ daemon.display_name }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appDaemonBadge {
|
||||
color: var(--color-text-maxcontrast);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-text-maxcontrast);
|
||||
|
|
@ -2,19 +2,9 @@
|
|||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<span
|
||||
v-if="isSupported || isFeatured"
|
||||
class="app-level-badge"
|
||||
:class="{ 'app-level-badge--supported': isSupported }"
|
||||
:title="badgeTitle">
|
||||
<NcIconSvgWrapper :path="badgeIcon" :size="20" inline />
|
||||
{{ badgeText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiCheck, mdiStarShootingOutline } from '@mdi/js'
|
||||
import { mdiStar, mdiStarShootingOutline } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
|
@ -28,15 +18,27 @@ const props = defineProps<{
|
|||
|
||||
const isSupported = computed(() => props.level === 300)
|
||||
const isFeatured = computed(() => props.level === 200)
|
||||
const badgeIcon = computed(() => isSupported.value ? mdiStarShootingOutline : mdiCheck)
|
||||
const badgeText = computed(() => isSupported.value ? t('settings', 'Supported') : t('settings', 'Featured'))
|
||||
const badgeIcon = computed(() => isSupported.value
|
||||
? mdiStarShootingOutline
|
||||
: mdiStar)
|
||||
const badgeText = computed(() => isSupported.value ? t('appstore', 'Supported') : t('appstore', 'Featured'))
|
||||
const badgeTitle = computed(() => isSupported.value
|
||||
? t('settings', 'This app is supported via your current Nextcloud subscription.')
|
||||
: t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.'))
|
||||
? t('appstore', 'This app is supported via your current Nextcloud subscription.')
|
||||
: t('appstore', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.'))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-level-badge {
|
||||
<template>
|
||||
<span
|
||||
v-if="isSupported || isFeatured"
|
||||
:class="[ $style.appLevelBadge, { [$style.appLevelBadge__supported]: isSupported } ]"
|
||||
:title="badgeTitle">
|
||||
<NcIconSvgWrapper :path="badgeIcon" :size="20" inline />
|
||||
{{ badgeText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appLevelBadge {
|
||||
color: var(--color-text-maxcontrast);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-text-maxcontrast);
|
||||
|
|
@ -44,14 +46,14 @@ const badgeTitle = computed(() => isSupported.value
|
|||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
gap: var(--default-grid-baseline);
|
||||
padding: 3px 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&--supported {
|
||||
background-color: var(--color-success);
|
||||
border-color: var(--color-border-success);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
.appLevelBadge__supported {
|
||||
background-color: var(--color-success);
|
||||
border-color: var(--color-border-success);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
</style>
|
||||
61
apps/appstore/src/components/BadgeAppScore.vue
Normal file
61
apps/appstore/src/components/BadgeAppScore.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<!--
|
||||
- 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 { mdiStar, mdiStarHalfFull, mdiStarOutline } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
const { app } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
}>()
|
||||
|
||||
const isShown = computed(() => app.ratingNumOverall && app.ratingNumOverall > 5)
|
||||
const score = computed(() => app.ratingOverall ?? 4)
|
||||
|
||||
const title = computed(() => {
|
||||
const appScore = (score.value * 5).toFixed(1)
|
||||
return t('appstore', 'Community rating: {score}/5', { score: appScore })
|
||||
})
|
||||
|
||||
const fullStars = computed(() => Math.floor(score.value * 5 + 0.25))
|
||||
const emptyStars = computed(() => Math.min(Math.floor((1 - score.value) * 5 + 0.25), 5 - fullStars.value))
|
||||
const hasHalfStar = computed(() => (fullStars.value + emptyStars.value) < 5)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="isShown"
|
||||
role="img"
|
||||
:aria-label="title"
|
||||
:title="title"
|
||||
:class="$style.badgeAppScore">
|
||||
<NcIconSvgWrapper
|
||||
v-for="index in fullStars"
|
||||
:key="`full-star-${index}`"
|
||||
:path="mdiStar"
|
||||
inline />
|
||||
<NcIconSvgWrapper v-if="hasHalfStar" :path="mdiStarHalfFull" inline />
|
||||
<NcIconSvgWrapper
|
||||
v-for="index in emptyStars"
|
||||
:key="`empty-star-${index}`"
|
||||
:path="mdiStarOutline"
|
||||
inline />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.badgeAppScore {
|
||||
display: inline-flex;
|
||||
color: var(--color-favorite, #a08b00);
|
||||
|
||||
> * {
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreExApp, IDeployDaemon } from '../../apps.d.ts'
|
||||
|
||||
import { mdiFormatListBulleted } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import DaemonSelectionDialogList from './DaemonSelectionDialogList.vue'
|
||||
import { useExAppsStore } from '../../store/exApps.ts'
|
||||
|
||||
defineProps<{
|
||||
/**
|
||||
* The app to enable
|
||||
*/
|
||||
app: IAppstoreExApp
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: [daemon?: IDeployDaemon]
|
||||
}>()
|
||||
|
||||
const store = useExAppsStore()
|
||||
const appApiAdminPage = generateUrl('/settings/admin/app_api')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcDialog
|
||||
:name="t('appstore', 'Choose Deploy Daemon for {appName}', { appName: app.name })"
|
||||
size="normal"
|
||||
@update:open="$event || $emit('close')">
|
||||
<NcEmptyContent
|
||||
v-if="store.dockerDaemons.length === 0"
|
||||
class="daemon-selection-list__empty-content"
|
||||
:name="t('appstore', 'No Deploy daemons configured')"
|
||||
:description="t('appstore', 'Register a custom one or setup from available templates')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFormatListBulleted" />
|
||||
</template>
|
||||
<template #action>
|
||||
<NcButton :href="appApiAdminPage">
|
||||
{{ t('appstore', 'Manage Deploy daemons') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<DaemonSelectionDialogList
|
||||
v-else
|
||||
:app="app"
|
||||
@selected="$emit('close', $event)" />
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IDeployDaemon } from '../../apps.d.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import DaemonSelectionDialogListEntry from './DaemonSelectionDialogListEntry.vue'
|
||||
import { useExAppsStore } from '../../store/exApps.ts'
|
||||
|
||||
defineEmits<{
|
||||
selected: [daemon: IDeployDaemon]
|
||||
}>()
|
||||
|
||||
const store = useExAppsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
:class="$style.DaemonSelectionDialogList"
|
||||
:aria-label="t('appstore', 'Registered Deploy daemons list')">
|
||||
<DaemonSelectionDialogListEntry
|
||||
v-for="daemon in store.dockerDaemons"
|
||||
:key="daemon.id"
|
||||
:daemon
|
||||
:isDefault="store.defaultDaemon?.name === daemon.name"
|
||||
@selected="$emit('selected', daemon)" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.DaemonSelectionDialogList {
|
||||
max-height: 350px;
|
||||
overflow-y: scroll;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IDeployDaemon } from '../../apps.d.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* The daemon to use
|
||||
*/
|
||||
daemon: IDeployDaemon
|
||||
/**
|
||||
* Whether this daemon is the default one
|
||||
*/
|
||||
isDefault: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selected: []
|
||||
}>()
|
||||
|
||||
const itemTitle = computed(() => `${props.daemon.name} - ${props.daemon.display_name}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcListItem
|
||||
:active="isDefault"
|
||||
:counterNumber="daemon.exAppsCount"
|
||||
counterType="highlighted"
|
||||
:details="isDefault ? t('appstore', 'Default') : ''"
|
||||
forceDisplayActions
|
||||
:name="itemTitle"
|
||||
@click.stop="emit('selected')">
|
||||
<template #subname>
|
||||
{{ daemon.accepts_deploy_id }}
|
||||
</template>
|
||||
</NcListItem>
|
||||
</template>
|
||||
|
|
@ -2,16 +2,31 @@
|
|||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppDiscoverApp } from '../../apps-discover.d.ts'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import AppImage from '../AppImage.vue'
|
||||
import AppLink from '../AppLink.vue'
|
||||
import BadgeAppScore from '../BadgeAppScore.vue'
|
||||
import { useAppsStore } from '../../store/apps.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: IAppDiscoverApp
|
||||
}>()
|
||||
|
||||
const store = useAppsStore()
|
||||
const app = computed(() => store.getAppById(props.modelValue.appId))
|
||||
|
||||
const appStoreLink = computed(() => props.modelValue.appId
|
||||
? `https://apps.nextcloud.com/apps/${props.modelValue.appId}`
|
||||
: '#')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppItem
|
||||
v-if="app"
|
||||
:app="app"
|
||||
category="discover"
|
||||
class="app-discover-app"
|
||||
inline
|
||||
:list-view="false" />
|
||||
<a
|
||||
v-else
|
||||
v-if="!app"
|
||||
class="app-discover-app app-discover-app__skeleton"
|
||||
:href="appStoreLink"
|
||||
target="_blank"
|
||||
|
|
@ -24,32 +39,54 @@
|
|||
<span class="skeleton-element" />
|
||||
<span class="skeleton-element" />
|
||||
</a>
|
||||
|
||||
<article v-else class="app-discover-app">
|
||||
<AppImage class="app-discover-app__image" :app="app" />
|
||||
<div class="app-discover-app__wrapper">
|
||||
<h3 class="app-discover-app__name">
|
||||
<AppLink :href="`app:${app.id}`">
|
||||
{{ app.name }}
|
||||
</AppLink>
|
||||
</h3>
|
||||
<p>{{ app.summary }}</p>
|
||||
<BadgeAppScore
|
||||
class="app-discover-app__score"
|
||||
:app />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppDiscoverApp } from '../../constants/AppDiscoverTypes.ts'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import AppItem from '../AppList/AppItem.vue'
|
||||
import { useAppsStore } from '../../store/apps-store.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: IAppDiscoverApp
|
||||
}>()
|
||||
|
||||
const store = useAppsStore()
|
||||
const app = computed(() => store.getAppById(props.modelValue.appId))
|
||||
|
||||
const appStoreLink = computed(() => props.modelValue.appId ? `https://apps.nextcloud.com/apps/${props.modelValue.appId}` : '#')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-discover-app {
|
||||
width: 100% !important; // full with of the showcase item
|
||||
border-radius: var(--border-radius-element);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100% !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
border-radius: var(--border-radius-rounded);
|
||||
}
|
||||
|
||||
&__image {
|
||||
height: 96px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-block: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
&__score {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: calc(2 * var(--default-grid-baseline));
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
&__skeleton {
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<section :aria-roledescription="t('settings', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
|
||||
<section :aria-roledescription="t('appstore', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
|
||||
<h3 v-if="headline" :id="headingId">
|
||||
{{ translatedHeadline }}
|
||||
</h3>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
<NcButton
|
||||
class="app-discover-carousel__button app-discover-carousel__button--previous"
|
||||
variant="tertiary-no-background"
|
||||
:aria-label="t('settings', 'Previous slide')"
|
||||
:aria-label="t('appstore', 'Previous slide')"
|
||||
:disabled="!hasPrevious"
|
||||
@click="currentIndex -= 1">
|
||||
<template #icon>
|
||||
|
|
@ -22,11 +22,11 @@
|
|||
</div>
|
||||
|
||||
<Transition :name="transitionName" mode="out-in">
|
||||
<PostType
|
||||
<DiscoverTypePost
|
||||
v-bind="shownElement"
|
||||
:key="shownElement.id ?? currentIndex"
|
||||
:aria-labelledby="`${internalId}-tab-${currentIndex}`"
|
||||
:dom-id="`${internalId}-tabpanel-${currentIndex}`"
|
||||
:domId="`${internalId}-tabpanel-${currentIndex}`"
|
||||
inline
|
||||
role="tabpanel" />
|
||||
</Transition>
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
<NcButton
|
||||
class="app-discover-carousel__button app-discover-carousel__button--next"
|
||||
variant="tertiary-no-background"
|
||||
:aria-label="t('settings', 'Next slide')"
|
||||
:aria-label="t('appstore', 'Next slide')"
|
||||
:disabled="!hasNext"
|
||||
@click="currentIndex += 1">
|
||||
<template #icon>
|
||||
|
|
@ -44,12 +44,12 @@
|
|||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('settings', 'Choose slide to display')">
|
||||
<div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('appstore', 'Choose slide to display')">
|
||||
<NcButton
|
||||
v-for="index of content.length"
|
||||
:id="`${internalId}-tab-${index}`"
|
||||
:key="index"
|
||||
:aria-label="t('settings', '{index} of {total}', { index, total: content.length })"
|
||||
:aria-label="t('appstore', '{index} of {total}', { index, total: content.length })"
|
||||
:aria-controls="`${internalId}-tabpanel-${index}`"
|
||||
:aria-selected="`${currentIndex === (index - 1)}`"
|
||||
role="tab"
|
||||
|
|
@ -63,85 +63,53 @@
|
|||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { IAppDiscoverCarousel } from '../../constants/AppDiscoverTypes.ts'
|
||||
import type { IAppDiscoverCarousel } from '../../apps-discover.d.ts'
|
||||
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiCircleOutline, mdiCircleSlice8 } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { computed, defineComponent, nextTick, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import PostType from './PostType.vue'
|
||||
import DiscoverTypePost from './DiscoverTypePost.vue'
|
||||
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
|
||||
import { commonAppDiscoverProps } from './common.ts'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CarouselType',
|
||||
const props = defineProps({
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
components: {
|
||||
NcButton,
|
||||
NcIconSvgWrapper,
|
||||
PostType,
|
||||
/**
|
||||
* The content of the carousel
|
||||
*/
|
||||
content: {
|
||||
type: Array as PropType<IAppDiscoverCarousel['content']>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
props: {
|
||||
...commonAppDiscoverProps,
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
|
||||
/**
|
||||
* The content of the carousel
|
||||
*/
|
||||
content: {
|
||||
type: Array as PropType<IAppDiscoverCarousel['content']>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
const currentIndex = ref(Math.min(1, props.content.length - 1))
|
||||
const shownElement = ref(props.content[currentIndex.value]!)
|
||||
const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
|
||||
const hasPrevious = computed(() => currentIndex.value > 0)
|
||||
|
||||
setup(props) {
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
|
||||
const headingId = computed(() => `${internalId.value}-h`)
|
||||
|
||||
const currentIndex = ref(Math.min(1, props.content.length - 1))
|
||||
const shownElement = ref(props.content[currentIndex.value])
|
||||
const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
|
||||
const hasPrevious = computed(() => currentIndex.value > 0)
|
||||
const transitionName = ref('slide-in')
|
||||
watch(() => currentIndex.value, (o, n) => {
|
||||
if (o < n) {
|
||||
transitionName.value = 'slide-in'
|
||||
} else {
|
||||
transitionName.value = 'slide-out'
|
||||
}
|
||||
|
||||
const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
|
||||
const headingId = computed(() => `${internalId.value}-h`)
|
||||
|
||||
const transitionName = ref('slide-in')
|
||||
watch(() => currentIndex.value, (o, n) => {
|
||||
if (o < n) {
|
||||
transitionName.value = 'slide-in'
|
||||
} else {
|
||||
transitionName.value = 'slide-out'
|
||||
}
|
||||
|
||||
// Wait next tick
|
||||
nextTick(() => {
|
||||
shownElement.value = props.content[currentIndex.value]
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
t,
|
||||
internalId,
|
||||
headingId,
|
||||
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
currentIndex,
|
||||
shownElement,
|
||||
|
||||
transitionName,
|
||||
|
||||
translatedHeadline,
|
||||
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiCircleOutline,
|
||||
mdiCircleSlice8,
|
||||
}
|
||||
},
|
||||
// Wait next tick
|
||||
nextTick(() => {
|
||||
shownElement.value = props.content[currentIndex.value]!
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
:type="source.mime">
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="generatePrivacyUrl(mediaSources[0].src)"
|
||||
:src="generatePrivacyUrl(mediaSources[0]!.src)"
|
||||
:alt="mediaAlt">
|
||||
</component>
|
||||
<div class="app-discover-post__play-icon-wrapper">
|
||||
|
|
@ -61,139 +61,109 @@
|
|||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { IAppDiscoverPost } from '../../constants/AppDiscoverTypes.ts'
|
||||
import type { IAppDiscoverPost } from '../../apps-discover.d.ts'
|
||||
|
||||
import { mdiPlayCircleOutline } from '@mdi/js'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { useElementSize, useElementVisibility } from '@vueuse/core'
|
||||
import { computed, defineComponent, ref, watchEffect } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import AppLink from './AppLink.vue'
|
||||
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
|
||||
import { commonAppDiscoverProps } from './common.ts'
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
AppLink,
|
||||
NcIconSvgWrapper,
|
||||
const props = defineProps({
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
text: {
|
||||
type: Object as PropType<IAppDiscoverPost['text']>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
|
||||
props: {
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
text: {
|
||||
type: Object as PropType<IAppDiscoverPost['text']>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
|
||||
media: {
|
||||
type: Object as PropType<IAppDiscoverPost['media']>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
domId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
media: {
|
||||
type: Object as PropType<IAppDiscoverPost['media']>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
const translatedText = useLocalizedValue(computed(() => props.text))
|
||||
const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
|
||||
const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
|
||||
domId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const isImage = computed(() => mediaSources?.value?.[0].mime.startsWith('image/') === true)
|
||||
/**
|
||||
* Is the media is shown full width
|
||||
*/
|
||||
const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
const translatedText = useLocalizedValue(computed(() => props.text))
|
||||
const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
|
||||
|
||||
/**
|
||||
* Link on the media
|
||||
* Fallback to post link to prevent link inside link (which is invalid HTML)
|
||||
*/
|
||||
const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
|
||||
const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
|
||||
const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
|
||||
|
||||
const hasPlaybackEnded = ref(false)
|
||||
const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
|
||||
const isImage = computed(() => mediaSources.value?.[0]?.mime.startsWith('image/') === true)
|
||||
/**
|
||||
* Is the media is shown full width
|
||||
*/
|
||||
const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
|
||||
|
||||
/**
|
||||
* The content is sized / styles are applied based on the container width
|
||||
* To make it responsive even for inline usage and when opening / closing the sidebar / navigation
|
||||
*/
|
||||
const container = ref<HTMLElement>()
|
||||
const { width: containerWidth } = useElementSize(container)
|
||||
const isSmallWidth = computed(() => containerWidth.value < 600)
|
||||
/**
|
||||
* Link on the media
|
||||
* Fallback to post link to prevent link inside link (which is invalid HTML)
|
||||
*/
|
||||
const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
|
||||
|
||||
/**
|
||||
* Generate URL for cached media to prevent user can be tracked
|
||||
*
|
||||
* @param url The URL to resolve
|
||||
*/
|
||||
const generatePrivacyUrl = (url: string) => url.startsWith('/')
|
||||
? url
|
||||
: generateOcsUrl('/apps/appstore/api/v1/discover/media?fileName={fileName}', { fileName: url })
|
||||
const hasPlaybackEnded = ref(false)
|
||||
const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
|
||||
|
||||
const mediaElement = ref<HTMLVideoElement | HTMLPictureElement>()
|
||||
const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
|
||||
watchEffect(() => {
|
||||
// Only if media is video
|
||||
if (!isImage.value && mediaElement.value) {
|
||||
const video = mediaElement.value as HTMLVideoElement
|
||||
/**
|
||||
* The content is sized / styles are applied based on the container width
|
||||
* To make it responsive even for inline usage and when opening / closing the sidebar / navigation
|
||||
*/
|
||||
const container = ref<HTMLElement>()
|
||||
const { width: containerWidth } = useElementSize(container)
|
||||
const isSmallWidth = computed(() => containerWidth.value < 600)
|
||||
|
||||
if (mediaIsVisible.value) {
|
||||
// Ensure video is muted - otherwise .play() will be blocked by browsers
|
||||
video.muted = true
|
||||
// If visible start playback
|
||||
video.play()
|
||||
} else {
|
||||
// If not visible pause the playback
|
||||
video.pause()
|
||||
// If the animation has ended reset
|
||||
if (video.ended) {
|
||||
video.currentTime = 0
|
||||
hasPlaybackEnded.value = false
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate URL for cached media to prevent user can be tracked
|
||||
*
|
||||
* @param url The URL to resolve
|
||||
*/
|
||||
function generatePrivacyUrl(url: string) {
|
||||
return url.startsWith('/')
|
||||
? url
|
||||
: generateOcsUrl('/apps/appstore/api/v1/discover/media?fileName={fileName}', { fileName: url })
|
||||
}
|
||||
|
||||
const mediaElement = ref<HTMLVideoElement | HTMLPictureElement>()
|
||||
const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
|
||||
watchEffect(() => {
|
||||
// Only if media is video
|
||||
if (!isImage.value && mediaElement.value) {
|
||||
const video = mediaElement.value as HTMLVideoElement
|
||||
|
||||
if (mediaIsVisible.value) {
|
||||
// Ensure video is muted - otherwise .play() will be blocked by browsers
|
||||
video.muted = true
|
||||
// If visible start playback
|
||||
video.play()
|
||||
} else {
|
||||
// If not visible pause the playback
|
||||
video.pause()
|
||||
// If the animation has ended reset
|
||||
if (video.ended) {
|
||||
video.currentTime = 0
|
||||
hasPlaybackEnded.value = false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
mdiPlayCircleOutline,
|
||||
|
||||
container,
|
||||
|
||||
translatedText,
|
||||
translatedHeadline,
|
||||
mediaElement,
|
||||
mediaSources,
|
||||
mediaAlt,
|
||||
mediaLink,
|
||||
|
||||
hasPlaybackEnded,
|
||||
showPlayVideo,
|
||||
|
||||
isFullWidth,
|
||||
isSmallWidth,
|
||||
isImage,
|
||||
|
||||
generatePrivacyUrl,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -16,71 +16,50 @@
|
|||
<ul class="app-discover-showcase__list">
|
||||
<li
|
||||
v-for="(item, index) of content"
|
||||
:key="item.id ?? index"
|
||||
:key="'id' in item ? item.id : index"
|
||||
class="app-discover-showcase__item">
|
||||
<PostType
|
||||
<DiscoverTypePost
|
||||
v-if="item.type === 'post'"
|
||||
v-bind="item"
|
||||
inline />
|
||||
<AppType v-else-if="item.type === 'app'" :model-value="item" />
|
||||
<DiscoverTypeApp v-else-if="item.type === 'app'" :modelValue="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { IAppDiscoverShowcase } from '../../constants/AppDiscoverTypes.ts'
|
||||
import type { IAppDiscoverShowcase } from '../../apps-discover.d.ts'
|
||||
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, defineComponent, ref } from 'vue'
|
||||
import AppType from './AppType.vue'
|
||||
import PostType from './PostType.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import DiscoverTypeApp from './DiscoverTypeApp.vue'
|
||||
import DiscoverTypePost from './DiscoverTypePost.vue'
|
||||
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
|
||||
import { commonAppDiscoverProps } from './common.ts'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ShowcaseType',
|
||||
const props = defineProps({
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
components: {
|
||||
AppType,
|
||||
PostType,
|
||||
},
|
||||
|
||||
props: {
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
/**
|
||||
* The content of the carousel
|
||||
*/
|
||||
content: {
|
||||
type: Array as PropType<IAppDiscoverShowcase['content']>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
|
||||
/**
|
||||
* Make the element responsive based on the container width to also handle open navigation or sidebar
|
||||
*/
|
||||
const container = ref<HTMLElement>()
|
||||
const { width: containerWidth } = useElementSize(container)
|
||||
const isSmallWidth = computed(() => containerWidth.value < 768)
|
||||
const isExtraSmallWidth = computed(() => containerWidth.value < 512)
|
||||
|
||||
return {
|
||||
t,
|
||||
|
||||
container,
|
||||
isSmallWidth,
|
||||
isExtraSmallWidth,
|
||||
translatedHeadline,
|
||||
}
|
||||
/**
|
||||
* The content of the carousel
|
||||
*/
|
||||
content: {
|
||||
type: Array as PropType<IAppDiscoverShowcase['content']>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
|
||||
/**
|
||||
* Make the element responsive based on the container width to also handle open navigation or sidebar
|
||||
*/
|
||||
const container = ref<HTMLElement>()
|
||||
const { width: containerWidth } = useElementSize(container)
|
||||
const isSmallWidth = computed(() => containerWidth.value < 768)
|
||||
const isExtraSmallWidth = computed(() => containerWidth.value < 512)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
|
@ -3,9 +3,9 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { PropType } from 'vue'
|
||||
import type { IAppDiscoverElement } from '../../constants/AppDiscoverTypes.ts'
|
||||
import type { IAppDiscoverElement } from '../../apps-discover.d.ts'
|
||||
|
||||
import { APP_DISCOVER_KNOWN_TYPES } from '../../constants/AppDiscoverTypes.ts'
|
||||
import { APP_DISCOVER_KNOWN_TYPES } from '../../constants.ts'
|
||||
|
||||
/**
|
||||
* Common Props for all app discover types
|
||||
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>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cleanup, render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import Markdown from './Markdown.vue'
|
||||
|
||||
describe('Markdown component', () => {
|
||||
beforeEach(cleanup)
|
||||
|
||||
it('renders links', () => {
|
||||
const component = render(Markdown, {
|
||||
props: {
|
||||
text: 'This is [a link](http://example.com)!',
|
||||
},
|
||||
})
|
||||
|
||||
const link = component.getByRole('link')
|
||||
expect(link).toBeInstanceOf(HTMLAnchorElement)
|
||||
expect(link.getAttribute('href')).toBe('http://example.com')
|
||||
expect(link.textContent).toBe('a link')
|
||||
})
|
||||
|
||||
it('renders headings', () => {
|
||||
const component = render(Markdown, {
|
||||
props: {
|
||||
text: '# level 1\nText\n## level 2\nText\n### level 3\nText\n#### level 4\nText\n##### level 5\nText\n###### level 6\nText\n',
|
||||
},
|
||||
})
|
||||
|
||||
for (let level = 1; level <= 6; level++) {
|
||||
const heading = component.getByRole('heading', { level })
|
||||
expect(heading.textContent).toBe(`level ${level}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('can limit headings', async () => {
|
||||
const component = render(Markdown, {
|
||||
props: {
|
||||
text: '# level 1\nText\n## level 2\nText\n### level 3\nText\n#### level 4\nText\n##### level 5\nText\n###### level 6\nText\n',
|
||||
minHeading: 4,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(component.findByRole('heading', { level: 1 })).rejects.toThrow()
|
||||
await expect(component.findByRole('heading', { level: 2 })).rejects.toThrow()
|
||||
await expect(component.findByRole('heading', { level: 3 })).rejects.toThrow()
|
||||
|
||||
expect(component.getByRole('heading', { level: 4 }).textContent).toBe('level 1')
|
||||
expect(component.getByRole('heading', { level: 5 }).textContent).toBe('level 2')
|
||||
await expect(component.findByRole('heading', { level: 6, name: 'level 3' })).resolves.not.toThrow()
|
||||
await expect(component.findByRole('heading', { level: 6, name: 'level 4' })).resolves.not.toThrow()
|
||||
await expect(component.findByRole('heading', { level: 6, name: 'level 5' })).resolves.not.toThrow()
|
||||
await expect(component.findByRole('heading', { level: 6, name: 'level 6' })).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html This is rendered markdown so should be "safe" -->
|
||||
<div class="settings-markdown" v-html="renderMarkdown" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import dompurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
|
||||
/* eslint vue/multi-word-component-names: "warn" */
|
||||
export default {
|
||||
name: 'Markdown',
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
minHeading: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
renderMarkdown() {
|
||||
const renderer = new marked.Renderer()
|
||||
renderer.link = function({ href, title, text }) {
|
||||
let prot
|
||||
try {
|
||||
prot = decodeURIComponent(unescape(href))
|
||||
.replace(/[^\w:]/g, '')
|
||||
.toLowerCase()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (prot.indexOf('http:') !== 0 && prot.indexOf('https:') !== 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let out = '<a href="' + href + '" rel="noreferrer noopener"'
|
||||
if (title) {
|
||||
out += ' title="' + title + '"'
|
||||
}
|
||||
out += '>' + text + '</a>'
|
||||
return out
|
||||
}
|
||||
renderer.heading = ({ text, depth }) => {
|
||||
depth = Math.min(6, depth + (this.minHeading - 1))
|
||||
return `<h${depth}>${text}</h${depth}>`
|
||||
}
|
||||
renderer.image = ({ title, text }) => {
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
return title
|
||||
}
|
||||
renderer.blockquote = ({ text }) => {
|
||||
return `<blockquote>${text}</blockquote>`
|
||||
}
|
||||
return dompurify.sanitize(
|
||||
marked(this.text.trim(), {
|
||||
renderer,
|
||||
gfm: false,
|
||||
highlight: false,
|
||||
tables: false,
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
sanitize: true,
|
||||
smartLists: true,
|
||||
smartypants: false,
|
||||
}),
|
||||
{
|
||||
SAFE_FOR_JQUERY: true,
|
||||
ALLOWED_TAGS: [
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'strong',
|
||||
'p',
|
||||
'a',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'em',
|
||||
'del',
|
||||
'blockquote',
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-markdown :deep {
|
||||
a {
|
||||
text-decoration: underline;
|
||||
&::after {
|
||||
content: '↗';
|
||||
padding-inline: calc(var(--default-grid-baseline) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
background-color: var(--color-background-dark);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1em 1.3em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
p code {
|
||||
background-color: var(--color-background-dark);
|
||||
border-radius: var(--border-radius);
|
||||
padding: .1em .3em;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-inline-start: 10px;
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ul > li > ul > li {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
ul > li > ul > li ul li {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-inline-start: 1em;
|
||||
border-inline-start: 4px solid var(--color-primary-element);
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-inline: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
apps/appstore/src/components/MarkdownPreview.spec.ts
Normal file
33
apps/appstore/src/components/MarkdownPreview.spec.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cleanup, render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import MarkdownPreview from './MarkdownPreview.vue'
|
||||
|
||||
describe('MarkdownPreview component', () => {
|
||||
beforeEach(cleanup)
|
||||
|
||||
it('renders', () => {
|
||||
const component = render(MarkdownPreview, {
|
||||
props: {
|
||||
minHeadingLevel: 2,
|
||||
text: `# Heading one
|
||||
This is [a link](http://example.com)!
|
||||
## Heading two
|
||||
> This is a block quote
|
||||
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(component.getByRole('heading', { level: 2, name: 'Heading one' })).toBeTruthy()
|
||||
expect(component.getByRole('heading', { level: 3, name: 'Heading two' })).toBeTruthy()
|
||||
expect(component.getByText('This is a block quote')).toBeInstanceOf(HTMLQuoteElement)
|
||||
expect(component.getByRole('link', { name: 'a link' })).toBeInstanceOf(HTMLAnchorElement)
|
||||
expect(component.getByRole('link', { name: 'a link' }).getAttribute('href')).toBe('http://example.com')
|
||||
expect(() => component.getByRole('img')).toThrow() // its a text
|
||||
})
|
||||
})
|
||||
84
apps/appstore/src/components/MarkdownPreview.vue
Normal file
84
apps/appstore/src/components/MarkdownPreview.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMarkdown } from '../composables/useMarkdown.ts'
|
||||
|
||||
const {
|
||||
text,
|
||||
minHeadingLevel = 1,
|
||||
} = defineProps<{
|
||||
/**
|
||||
* The markdown text to render
|
||||
*/
|
||||
text: string
|
||||
/**
|
||||
* Limit the minimum heading level
|
||||
*/
|
||||
minHeadingLevel?: number
|
||||
}>()
|
||||
|
||||
const renderMarkdown = useMarkdown(() => text, { minHeadingLevel })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="settings-markdown" v-html="renderMarkdown" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-markdown :deep {
|
||||
a {
|
||||
text-decoration: underline;
|
||||
&::after {
|
||||
content: '↗';
|
||||
padding-inline: calc(var(--default-grid-baseline) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
background-color: var(--color-background-dark);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1em 1.3em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
p code {
|
||||
background-color: var(--color-background-dark);
|
||||
border-radius: var(--border-radius);
|
||||
padding: .1em .3em;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-inline-start: 10px;
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ul > li > ul > li {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
ul > li > ul > li ul li {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-inline-start: 1em;
|
||||
border-inline-start: 4px solid var(--color-primary-element);
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-inline: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
apps/appstore/src/composables/useActions.ts
Normal file
19
apps/appstore/src/composables/useActions.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { computed, toValue } from 'vue'
|
||||
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 | null>) {
|
||||
return computed(() => toValue(app) ? actions.filter((action) => action.enabled(toValue(app)!)) : [])
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import type { IAppstoreApp } from '../app-types.ts'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiCog, mdiCogOutline } from '@mdi/js'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import AppstoreCategoryIcons from '../constants/AppstoreCategoryIcons.ts'
|
||||
import { APPSTORE_CATEGORY_ICONS } from '../constants.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
/**
|
||||
|
|
@ -16,7 +17,7 @@ import logger from '../utils/logger.ts'
|
|||
*
|
||||
* @param app The app to get the icon for
|
||||
*/
|
||||
export function useAppIcon(app: Ref<IAppstoreApp>) {
|
||||
export function useAppIcon(app: Ref<IAppstoreApp | IAppstoreExApp | null>) {
|
||||
const appIcon = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
|
|
@ -29,7 +30,7 @@ export function useAppIcon(app: Ref<IAppstoreApp>) {
|
|||
path = mdiCogOutline
|
||||
} else {
|
||||
path = [app.value?.category ?? []].flat()
|
||||
.map((name) => AppstoreCategoryIcons[name])
|
||||
.map((name) => APPSTORE_CATEGORY_ICONS[name])
|
||||
.filter((icon) => !!icon)
|
||||
.at(0)
|
||||
?? (!app.value?.app_api ? mdiCog : mdiCogOutline)
|
||||
|
|
@ -39,13 +40,13 @@ export function useAppIcon(app: Ref<IAppstoreApp>) {
|
|||
|
||||
watchEffect(async () => {
|
||||
// Note: Only variables until the first `await` will be watched!
|
||||
if (!app.value?.preview) {
|
||||
if (!app.value?.icon) {
|
||||
appIcon.value = categoryIcon.value
|
||||
} else {
|
||||
appIcon.value = null
|
||||
// Now try to load the real app icon
|
||||
try {
|
||||
const response = await window.fetch(app.value.preview)
|
||||
const response = await window.fetch(app.value.icon)
|
||||
const blob = await response.blob()
|
||||
const rawSvg = await blob.text()
|
||||
appIcon.value = rawSvg.replaceAll(/fill="#(fff|ffffff)([a-z0-9]{1,2})?"/ig, 'fill="currentColor"')
|
||||
|
|
|
|||
37
apps/appstore/src/composables/useFilteredApps.ts
Normal file
37
apps/appstore/src/composables/useFilteredApps.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/*!
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
*/
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserSettingsStore } from '../store/userSettings.ts'
|
||||
|
||||
/**
|
||||
* Get the filtered list of apps based on the user settings
|
||||
*
|
||||
* @param apps - The apps to filter
|
||||
*/
|
||||
export function useFilteredApps(apps: MaybeRefOrGetter<(IAppstoreApp | IAppstoreExApp)[]>) {
|
||||
const store = useUserSettingsStore()
|
||||
const route = useRoute()
|
||||
return computed(() => {
|
||||
const query = [route.query.q || ''].flat()[0]!
|
||||
return toValue(apps)
|
||||
.filter((app) => {
|
||||
if (!store.showIncompatible && app.isCompatible === false) {
|
||||
return false
|
||||
}
|
||||
if (query) {
|
||||
const needle = query.trim().toLocaleLowerCase()
|
||||
return app.name.toLocaleLowerCase().includes(needle)
|
||||
|| app.id.toLocaleLowerCase().includes(needle)
|
||||
|| app.summary.toLocaleLowerCase().includes(needle)
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -1,23 +1,13 @@
|
|||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { ILocalizedValue } from '../constants/AppDiscoverTypes.ts'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import type { ILocalizedValue } from '../apps-discover.d.ts'
|
||||
|
||||
import { getLanguage } from '@nextcloud/l10n'
|
||||
import {
|
||||
type Ref,
|
||||
|
||||
computed,
|
||||
} from 'vue'
|
||||
|
||||
/**
|
||||
* Helper to get the localized value for the current users language
|
||||
*
|
||||
* @param dict The dictionary to get the value from
|
||||
* @param language The language to use
|
||||
*/
|
||||
const getLocalizedValue = <T>(dict: ILocalizedValue<T>, language: string) => dict[language] ?? dict[language.split('_')[0]] ?? dict.en ?? null
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* Get the localized value of the dictionary provided
|
||||
|
|
@ -33,3 +23,13 @@ export function useLocalizedValue<T>(dict: Ref<ILocalizedValue<T | undefined> |
|
|||
|
||||
return computed(() => !dict?.value ? null : getLocalizedValue<T>(dict.value as ILocalizedValue<T>, language))
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the localized value for the current users language
|
||||
*
|
||||
* @param dict The dictionary to get the value from
|
||||
* @param language The language to use
|
||||
*/
|
||||
function getLocalizedValue<T>(dict: ILocalizedValue<T>, language: string) {
|
||||
return dict[language] ?? dict[language.split('_')[0]!] ?? dict.en ?? null
|
||||
}
|
||||
|
|
|
|||
52
apps/appstore/src/composables/useMarkdown.spec.ts
Normal file
52
apps/appstore/src/composables/useMarkdown.spec.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect, test } from 'vitest'
|
||||
import { useMarkdown } from './useMarkdown.ts'
|
||||
|
||||
test('renders links', () => {
|
||||
const rendered = useMarkdown('This is [a link](http://example.com)!')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<p>This is <a href="http://example.com" rel="noreferrer noopener">a link</a>!</p>\n"')
|
||||
})
|
||||
|
||||
test('removes links with invalid URL', () => {
|
||||
const rendered = useMarkdown('This is [a link](ftp://example.com)!')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<p>This is !</p>\n"')
|
||||
})
|
||||
|
||||
test('renders images', () => {
|
||||
const rendered = useMarkdown('')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<p>alt text</p>\n"')
|
||||
})
|
||||
|
||||
test('renders images with title', () => {
|
||||
const rendered = useMarkdown('')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<p>Title</p>\n"')
|
||||
})
|
||||
|
||||
test('renders images with alt text and title', () => {
|
||||
const rendered = useMarkdown('')
|
||||
expect(rendered.value).toMatchInlineSnapshot(`
|
||||
"<p>alt text</p>\n"
|
||||
`)
|
||||
})
|
||||
|
||||
test('renders block quotes', () => {
|
||||
const rendered = useMarkdown('> This is a block quote')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<blockquote>This is a block quote</blockquote>"')
|
||||
})
|
||||
|
||||
test('renders headings', () => {
|
||||
const rendered = useMarkdown('# level 1\n## level 2\n### level 3\n#### level 4\n##### level 5\n###### level 6\n')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<h1>level 1</h1><h2>level 2</h2><h3>level 3</h3><h4>level 4</h4><h5>level 5</h5><h6>level 6</h6>"')
|
||||
})
|
||||
|
||||
test('renders headings with minHeadingLevel', () => {
|
||||
const rendered = useMarkdown(
|
||||
'# level 1\n## level 2\n### level 3\n#### level 4\n##### level 5\n###### level 6\n',
|
||||
{ minHeadingLevel: 4 },
|
||||
)
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<h4>level 1</h4><h5>level 2</h5><h6>level 3</h6><h6>level 4</h6><h6>level 5</h6><h6>level 6</h6>"')
|
||||
})
|
||||
134
apps/appstore/src/composables/useMarkdown.ts
Normal file
134
apps/appstore/src/composables/useMarkdown.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Tokens } from 'marked'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import dompurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
export interface MarkdownOptions {
|
||||
minHeadingLevel?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Markdown to HTML
|
||||
*
|
||||
* @param text - The Markdown source
|
||||
* @param options - Markdown options
|
||||
*/
|
||||
export function useMarkdown(text: MaybeRefOrGetter<string>, options?: MarkdownOptions) {
|
||||
const renderer = new marked.Renderer()
|
||||
renderer.blockquote = markedBlockquote
|
||||
renderer.link = markedLink
|
||||
renderer.image = markedImage
|
||||
|
||||
return computed(() => {
|
||||
const minHeading = options?.minHeadingLevel ?? 1
|
||||
renderer.heading = getMarkedHeading(minHeading)
|
||||
const markdown = toValue(text).trim()
|
||||
|
||||
return dompurify.sanitize(
|
||||
marked(markdown, {
|
||||
async: false,
|
||||
renderer,
|
||||
gfm: false,
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
}),
|
||||
{
|
||||
ALLOWED_TAGS: [
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'strong',
|
||||
'p',
|
||||
'a',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'em',
|
||||
'del',
|
||||
'blockquote',
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom link renderer that only allows http and https links
|
||||
*
|
||||
* @param ctx - The render context
|
||||
* @param ctx.href - The link href
|
||||
* @param ctx.title - The link title
|
||||
* @param ctx.text - The link text
|
||||
*/
|
||||
function markedLink({ href, title, text }: Tokens.Link) {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(href)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return ''
|
||||
}
|
||||
|
||||
let out = '<a href="' + href + '" rel="noreferrer noopener"'
|
||||
if (title) {
|
||||
out += ' title="' + title + '"'
|
||||
}
|
||||
out += '>' + text.replaceAll(/(?<!\\)\\([^\\])/g, '$1') + '</a>'
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Only render image alt text or title
|
||||
*
|
||||
* @param ctx - The render context
|
||||
* @param ctx.title - The image title
|
||||
* @param ctx.text - The image alt text
|
||||
*/
|
||||
function markedImage({ title, text }: Tokens.Image): string {
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
return title ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render block quotes without any special styling
|
||||
*
|
||||
* @param ctx - The render context
|
||||
* @param ctx.text - The blockquote text
|
||||
*/
|
||||
function markedBlockquote({ text }: Tokens.Blockquote): string {
|
||||
return `<blockquote>${text}</blockquote>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a custom heading renderer that clamps heading levels
|
||||
*
|
||||
* @param minHeading - The heading to clamp to
|
||||
*/
|
||||
function getMarkedHeading(minHeading: number) {
|
||||
/**
|
||||
* Custom heading renderer that adjusts heading levels
|
||||
*
|
||||
* @param ctx - The render context
|
||||
* @param ctx.text - The heading text
|
||||
* @param ctx.depth - The heading depth
|
||||
*/
|
||||
return ({ text, depth }: Tokens.Heading): string => {
|
||||
depth = Math.min(6, depth + (minHeading - 1))
|
||||
return `<h${depth}>${text}</h${depth}>`
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
mdiAccountMultipleOutline,
|
||||
mdiAccountOutline,
|
||||
|
|
@ -28,11 +29,27 @@ import {
|
|||
mdiTools,
|
||||
mdiViewColumnOutline,
|
||||
} from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
/**
|
||||
* The names of the special appstore sections
|
||||
*/
|
||||
export const APPSTORE_CATEGORY_NAMES = Object.freeze({
|
||||
discover: t('appstore', 'Discover'),
|
||||
installed: t('appstore', 'Your apps'),
|
||||
enabled: t('appstore', 'Active apps'),
|
||||
disabled: t('appstore', 'Disabled apps'),
|
||||
updates: t('appstore', 'Updates'),
|
||||
bundles: t('appstore', 'App bundles'),
|
||||
featured: t('appstore', 'Featured apps'),
|
||||
supported: t('appstore', 'Supported apps'), // From subscription
|
||||
search: t('appstore', 'Search results'),
|
||||
})
|
||||
|
||||
/**
|
||||
* SVG paths used for appstore category icons
|
||||
*/
|
||||
export default Object.freeze({
|
||||
export const APPSTORE_CATEGORY_ICONS = Object.freeze({
|
||||
// system special categories
|
||||
discover: mdiStarCircleOutline,
|
||||
installed: mdiAccountOutline,
|
||||
|
|
@ -61,3 +78,8 @@ export default Object.freeze({
|
|||
tools: mdiTools,
|
||||
workflow: mdiClipboardFlowOutline,
|
||||
})
|
||||
|
||||
/**
|
||||
* Currently known types of app discover section elements
|
||||
*/
|
||||
export const APP_DISCOVER_KNOWN_TYPES = ['post', 'showcase', 'carousel'] as const
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
||||
/** Enum of verification constants, according to Apps */
|
||||
export const APPS_SECTION_ENUM = Object.freeze({
|
||||
discover: t('settings', 'Discover'),
|
||||
installed: t('settings', 'Your apps'),
|
||||
enabled: t('settings', 'Active apps'),
|
||||
disabled: t('settings', 'Disabled apps'),
|
||||
updates: t('settings', 'Updates'),
|
||||
'app-bundles': t('settings', 'App bundles'),
|
||||
featured: t('settings', 'Featured apps'),
|
||||
supported: t('settings', 'Supported apps'), // From subscription
|
||||
})
|
||||
|
|
@ -3,37 +3,16 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { getCSPNonce } from '@nextcloud/auth'
|
||||
import { n, t } from '@nextcloud/l10n'
|
||||
import { createPinia, PiniaVuePlugin } from 'pinia'
|
||||
import VTooltipPlugin from 'v-tooltip'
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import { sync } from 'vuex-router-sync'
|
||||
import App from './views/App.vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createApp } from 'vue'
|
||||
import AppstoreApp from './AppstoreApp.vue'
|
||||
import router from './router/index.ts'
|
||||
import { useStore } from './store/index.js'
|
||||
|
||||
// CSP config for webpack dynamic chunk loading
|
||||
|
||||
__webpack_nonce__ = getCSPNonce()
|
||||
|
||||
// bind to window
|
||||
Vue.prototype.t = t
|
||||
Vue.prototype.n = n
|
||||
Vue.use(PiniaVuePlugin)
|
||||
Vue.use(VTooltipPlugin, { defaultHtml: false })
|
||||
Vue.use(Vuex)
|
||||
|
||||
const store = useStore()
|
||||
sync(store, router)
|
||||
import 'vite/modulepreload-polyfill'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default new Vue({
|
||||
router,
|
||||
store,
|
||||
pinia,
|
||||
render: (h) => h(App),
|
||||
el: '#content',
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -4,17 +4,11 @@
|
|||
*/
|
||||
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import routes from './routes.ts'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
const router = new Router({
|
||||
mode: 'history',
|
||||
// if index.php is in the url AND we got this far, then it's working:
|
||||
// let's keep using index.php in the url
|
||||
base: generateUrl(''),
|
||||
const router = createRouter({
|
||||
history: createWebHistory(generateUrl('')),
|
||||
linkActiveClass: 'active',
|
||||
routes,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,43 +1,58 @@
|
|||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { RouteConfig } from 'vue-router'
|
||||
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
|
||||
const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
|
||||
const appstoreEnabled = loadState<boolean>('appstore', 'appstoreEnabled', true)
|
||||
|
||||
// Dynamic loading
|
||||
const AppStore = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStore.vue')
|
||||
const AppStoreNavigation = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStoreNavigation.vue')
|
||||
const AppStoreSidebar = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStoreSidebar.vue')
|
||||
const AppstoreDiscover = () => import('../views/AppstoreDiscover.vue')
|
||||
const AppstoreManage = () => import('../views/AppstoreManage.vue')
|
||||
const AppstoreBundles = () => import('../views/AppstoreBundles.vue')
|
||||
const AppstoreBrowse = () => import('../views/AppstoreBrowse.vue')
|
||||
const AppstoreSearch = () => import('../views/AppstoreSearch.vue')
|
||||
|
||||
const routes: RouteConfig[] = [
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/:index(index.php/)?settings/apps',
|
||||
name: 'apps',
|
||||
redirect: {
|
||||
name: 'apps-category',
|
||||
params: {
|
||||
category: appstoreEnabled ? 'discover' : 'installed',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
default: AppStore,
|
||||
navigation: AppStoreNavigation,
|
||||
sidebar: AppStoreSidebar,
|
||||
},
|
||||
redirect: appstoreEnabled
|
||||
? {
|
||||
name: 'apps-discover',
|
||||
}
|
||||
: {
|
||||
name: 'apps-manage',
|
||||
params: { category: 'installed' },
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: ':category',
|
||||
path: 'discover/:id?',
|
||||
name: 'apps-discover',
|
||||
component: AppstoreDiscover,
|
||||
},
|
||||
{
|
||||
path: 'bundles/:id?',
|
||||
name: 'apps-bundles',
|
||||
component: AppstoreBundles,
|
||||
},
|
||||
{
|
||||
path: ':category(installed|enabled|disabled|updates)/:id?',
|
||||
name: 'apps-manage',
|
||||
component: AppstoreManage,
|
||||
},
|
||||
{
|
||||
path: ':category/:id?',
|
||||
name: 'apps-category',
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
name: 'apps-details',
|
||||
},
|
||||
],
|
||||
component: AppstoreBrowse,
|
||||
},
|
||||
{
|
||||
path: 'search/:id?',
|
||||
name: 'apps-search',
|
||||
component: AppstoreSearch,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
104
apps/appstore/src/service/api.ts
Normal file
104
apps/appstore/src/service/api.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { OCSResponse } from '@nextcloud/typings/ocs'
|
||||
import type { IAppstoreApp, IAppstoreCategory } from '../apps.d.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import PQueue from 'p-queue'
|
||||
import { APPSTORE_CATEGORY_ICONS } from '../constants.ts'
|
||||
|
||||
addPasswordConfirmationInterceptors(axios)
|
||||
|
||||
const BASE_URL = generateOcsUrl('apps/appstore/api/v1')
|
||||
const Url = Object.freeze({
|
||||
apps: `${BASE_URL}/apps`,
|
||||
categories: `${BASE_URL}/apps/categories`,
|
||||
enable: `${BASE_URL}/apps/enable`,
|
||||
disable: `${BASE_URL}/apps/disable`,
|
||||
uninstall: `${BASE_URL}/apps/uninstall`,
|
||||
update: `${BASE_URL}/apps/update`,
|
||||
bundleEnable: `${BASE_URL}/bundles/enable`,
|
||||
})
|
||||
|
||||
const queue = new PQueue({ concurrency: 1 })
|
||||
|
||||
/**
|
||||
* Enable an app by its app id
|
||||
*
|
||||
* @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, groups?: string[]) {
|
||||
return queue.add(async () => {
|
||||
await axios.post(Url.enable, { appId, groups, force: force || undefined }, { confirmPassword: PwdConfirmationMode.Strict })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable app by its app id
|
||||
*
|
||||
* @param appId - The app to disable
|
||||
*/
|
||||
export async function disableApp(appId: string) {
|
||||
return queue.add(async () => {
|
||||
await axios.post(Url.disable, { appId }, { confirmPassword: PwdConfirmationMode.Lax })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an app by its app id
|
||||
*
|
||||
* @param appId - The app id to update
|
||||
*/
|
||||
export async function updateApp(appId: string) {
|
||||
return queue.add(async () => {
|
||||
await axios.post(Url.update, { appId }, { confirmPassword: PwdConfirmationMode.Strict })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an app by its app id
|
||||
*
|
||||
* @param appId - The app to uninstall
|
||||
*/
|
||||
export async function uninstallApp(appId: string) {
|
||||
return queue.add(async () => {
|
||||
await axios.post(Url.uninstall, { appId }, { confirmPassword: PwdConfirmationMode.Strict })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all apps from the appstore
|
||||
*/
|
||||
export async function getApps() {
|
||||
const { data } = await axios.get<OCSResponse<IAppstoreApp[]>>(Url.apps)
|
||||
return data.ocs.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app categories
|
||||
*/
|
||||
export async function getCategories() {
|
||||
const { data } = await axios.get<OCSResponse<IAppstoreCategory[]>>(Url.categories)
|
||||
for (const category of data.ocs.data) {
|
||||
category.icon = APPSTORE_CATEGORY_ICONS[category.id] ?? ''
|
||||
}
|
||||
return data.ocs.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable an app bundle by its id
|
||||
*
|
||||
* @param bundleId - The id of the bundle to enable
|
||||
*/
|
||||
export async function enableBundle(bundleId: string) {
|
||||
return queue.add(async () => {
|
||||
await axios.post(Url.bundleEnable, { bundleId }, { confirmPassword: PwdConfirmationMode.Strict })
|
||||
})
|
||||
}
|
||||
52
apps/appstore/src/service/app-discover.ts
Normal file
52
apps/appstore/src/service/app-discover.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { OCSResponse } from '@nextcloud/typings/ocs'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { filterElements, parseApiResponse } from '../utils/appDiscoverParser.ts'
|
||||
|
||||
/**
|
||||
* Get app discover elements
|
||||
*/
|
||||
export async function getDiscoverElements() {
|
||||
const data = await loadDiscoverElements()
|
||||
if (data.length === 0) {
|
||||
throw new Error('No app discover elements available (empty response)')
|
||||
}
|
||||
|
||||
// Parse data to ensure dates are useable and then filter out expired or future elements
|
||||
const parsedElements = data.map(parseApiResponse)
|
||||
.filter(filterElements)
|
||||
|
||||
// Shuffle elements to make it looks more interesting
|
||||
const shuffledElements = shuffleArray(parsedElements)
|
||||
// Sort pinned elements first
|
||||
shuffledElements.sort((a, b) => (a.order ?? Infinity) < (b.order ?? Infinity) ? -1 : 1)
|
||||
return shuffledElements
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle using the Fisher-Yates algorithm
|
||||
*
|
||||
* @param array The array to shuffle (in place)
|
||||
*/
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j]!, array[i]!]
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
/**
|
||||
* Load discover elements from the API
|
||||
*/
|
||||
async function loadDiscoverElements() {
|
||||
const response = await axios.get<OCSResponse<Record<string, unknown>[]>>(generateOcsUrl('/apps/appstore/api/v1/discover'))
|
||||
const { data } = response.data.ocs
|
||||
return data
|
||||
}
|
||||
71
apps/appstore/src/service/exAppApi.ts
Normal file
71
apps/appstore/src/service/exAppApi.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/*!
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
*/
|
||||
|
||||
import type { IAppstoreExApp, IDeployDaemon, IDeployOptions, IExAppStatus } from '../apps.d.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
/**
|
||||
* Fetch all external (app_api) apps from the server.
|
||||
*/
|
||||
export async function fetchApps() {
|
||||
const { data } = await axios.get(generateUrl('/apps/app_api/apps/list'))
|
||||
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.
|
||||
*
|
||||
* @param app - The app to enable
|
||||
* @param daemon - The daemon to use for deployment
|
||||
* @param deployOptions - Additional options for deployment
|
||||
*/
|
||||
export async function enableExApp(app: IAppstoreExApp, daemon: IDeployDaemon, deployOptions?: IDeployOptions) {
|
||||
await confirmPassword()
|
||||
await axios.post(generateUrl(`/apps/app_api/apps/enable/${app.id}/${daemon.name}`), { deployOptions })
|
||||
}
|
||||
|
||||
/**
|
||||
* Force enable an external app
|
||||
*
|
||||
* @param appId - The app to force-enable
|
||||
*/
|
||||
export async function forceEnableExApp(appId: string) {
|
||||
await confirmPassword()
|
||||
await axios.post(generateUrl('/apps/app_api/apps/force'), { appId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable an external app.
|
||||
*
|
||||
* @param appId - The app to disable
|
||||
*/
|
||||
export async function disableExApp(appId: string) {
|
||||
await confirmPassword()
|
||||
await axios.get(generateUrl(`apps/app_api/apps/disable/${appId}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an external app.
|
||||
*
|
||||
* @param appId - The app to uninstall
|
||||
* @param removeData - If all data should be removed
|
||||
*/
|
||||
export async function uninstallExApp(appId: string, removeData = false) {
|
||||
await confirmPassword()
|
||||
await axios.get(generateUrl(`/apps/app_api/apps/uninstall/${appId}?removeData=${removeData}`))
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { rebuildNavigation } from './service/rebuild-navigation.ts'
|
||||
|
||||
window.OC.Settings ??= {}
|
||||
window.OC.Settings.Apps ??= {
|
||||
rebuildNavigation,
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { addPasswordConfirmationInterceptors, confirmPassword } from '@nextcloud/password-confirmation'
|
||||
|
||||
addPasswordConfirmationInterceptors(axios)
|
||||
|
||||
/**
|
||||
* @param {string} url - The url to sanitize
|
||||
*/
|
||||
function sanitize(url) {
|
||||
return url.replace(/\/$/, '') // Remove last url slash
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
/**
|
||||
* This Promise is used to chain a request that require an admin password confirmation
|
||||
* Since chaining Promise have a very precise behavior concerning catch and then,
|
||||
* you'll need to be careful when using it.
|
||||
* e.g
|
||||
* // store
|
||||
* action(context) {
|
||||
* return api.requireAdmin().then((response) => {
|
||||
* return api.get('url')
|
||||
* .then((response) => {API success})
|
||||
* .catch((error) => {API failure});
|
||||
* }).catch((error) => {requireAdmin failure});
|
||||
* }
|
||||
* // vue
|
||||
* this.$store.dispatch('action').then(() => {always executed})
|
||||
*
|
||||
* Since Promise.then().catch().then() will always execute the last then
|
||||
* this.$store.dispatch('action').then will always be executed
|
||||
*
|
||||
* If you want requireAdmin failure to also catch the API request failure
|
||||
* you will need to throw a new error in the api.get.catch()
|
||||
*
|
||||
* e.g
|
||||
* api.requireAdmin().then((response) => {
|
||||
* api.get('url')
|
||||
* .then((response) => {API success})
|
||||
* .catch((error) => {throw error;});
|
||||
* }).catch((error) => {requireAdmin OR API failure});
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
requireAdmin() {
|
||||
return confirmPassword()
|
||||
},
|
||||
get(url, options) {
|
||||
return axios.get(sanitize(url), options)
|
||||
},
|
||||
post(url, data, options) {
|
||||
return axios.post(sanitize(url), data, options)
|
||||
},
|
||||
patch(url, data) {
|
||||
return axios.patch(sanitize(url), data)
|
||||
},
|
||||
put(url, data) {
|
||||
return axios.put(sanitize(url), data)
|
||||
},
|
||||
delete(url, data) {
|
||||
return axios.delete(sanitize(url), { params: data })
|
||||
},
|
||||
}
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreExApp, IDeployDaemon, IDeployOptions, IExAppStatus } from '../app-types.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { showError, showInfo } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { defineStore } from 'pinia'
|
||||
import Vue from 'vue'
|
||||
import logger from '../utils/logger.ts'
|
||||
import api from './api.js'
|
||||
|
||||
interface AppApiState {
|
||||
apps: IAppstoreExApp[]
|
||||
updateCount: number
|
||||
loading: Record<string, boolean>
|
||||
loadingList: boolean
|
||||
statusUpdater: number | null | undefined
|
||||
daemonAccessible: boolean
|
||||
defaultDaemon: IDeployDaemon | null
|
||||
dockerDaemons: IDeployDaemon[]
|
||||
}
|
||||
|
||||
export const useAppApiStore = defineStore('app-api-apps', {
|
||||
state: (): AppApiState => ({
|
||||
apps: [],
|
||||
updateCount: loadState('appstore', 'appstoreExAppUpdateCount', 0),
|
||||
loading: {},
|
||||
loadingList: false,
|
||||
statusUpdater: null,
|
||||
daemonAccessible: loadState('appstore', 'defaultDaemonConfigAccessible', false),
|
||||
defaultDaemon: loadState('appstore', 'defaultDaemonConfig', null),
|
||||
dockerDaemons: [],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getLoading: (state) => (id: string) => state.loading[id] ?? false,
|
||||
getAllApps: (state) => state.apps,
|
||||
getUpdateCount: (state) => state.updateCount,
|
||||
getDaemonAccessible: (state) => state.daemonAccessible,
|
||||
getDefaultDaemon: (state) => state.defaultDaemon,
|
||||
getAppStatus: (state) => (appId: string) => state.apps.find((app) => app.id === appId)?.status || null,
|
||||
getStatusUpdater: (state) => state.statusUpdater,
|
||||
getInitializingOrDeployingApps: (state) => state.apps.filter((app) => app?.status?.action
|
||||
&& (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck')
|
||||
&& app.status.type !== ''),
|
||||
},
|
||||
|
||||
actions: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
appsApiFailure(error: any) {
|
||||
showError(t('appstore', 'An error occurred during the request. Unable to proceed.') + '<br>' + error.error.response.data.data.message, { isHTML: true })
|
||||
logger.error(error)
|
||||
},
|
||||
|
||||
setLoading(id: string, value: boolean) {
|
||||
Vue.set(this.loading, id, value)
|
||||
},
|
||||
|
||||
setError(appId: string | string[], error: string) {
|
||||
const appIds = Array.isArray(appId) ? appId : [appId]
|
||||
appIds.forEach((_id) => {
|
||||
const app = this.apps.find((app) => app.id === _id)
|
||||
if (app) {
|
||||
app.error = error
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
enableApp(appId: string, daemon: IDeployDaemon, deployOptions: IDeployOptions) {
|
||||
this.setLoading(appId, true)
|
||||
this.setLoading('install', true)
|
||||
return confirmPassword().then(() => {
|
||||
return axios.post(generateUrl(`/apps/app_api/apps/enable/${appId}/${daemon.name}`), { deployOptions })
|
||||
.then((response) => {
|
||||
this.setLoading(appId, false)
|
||||
this.setLoading('install', false)
|
||||
|
||||
const app = this.apps.find((app) => app.id === appId)
|
||||
if (app) {
|
||||
if (!app.installed) {
|
||||
app.installed = true
|
||||
app.needsDownload = false
|
||||
app.daemon = daemon
|
||||
app.status = {
|
||||
type: 'install',
|
||||
action: 'deploy',
|
||||
init: 0,
|
||||
deploy: 0,
|
||||
} as IExAppStatus
|
||||
}
|
||||
app.active = true
|
||||
app.canUnInstall = false
|
||||
app.removable = true
|
||||
app.error = ''
|
||||
}
|
||||
|
||||
this.updateAppsStatus()
|
||||
|
||||
return axios.get(generateUrl('apps/files'))
|
||||
.then(() => {
|
||||
if (response.data.update_required) {
|
||||
showInfo(
|
||||
t('appstore', 'The app has been enabled but needs to be updated.'),
|
||||
{
|
||||
onClick: () => window.location.reload(),
|
||||
close: false,
|
||||
},
|
||||
)
|
||||
setTimeout(() => {
|
||||
location.reload()
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.setError(appId, t('appstore', 'Error: This app cannot be enabled because it makes the server unstable'))
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setLoading(appId, false)
|
||||
this.setLoading('install', false)
|
||||
this.setError(appId, error.response.data.data.message)
|
||||
this.appsApiFailure({ appId, error })
|
||||
})
|
||||
}).catch(() => {
|
||||
this.setLoading(appId, false)
|
||||
this.setLoading('install', false)
|
||||
})
|
||||
},
|
||||
|
||||
forceEnableApp(appId: string) {
|
||||
this.setLoading(appId, true)
|
||||
this.setLoading('install', true)
|
||||
return confirmPassword().then(() => {
|
||||
return api.post(generateUrl('/apps/app_api/apps/force'), { appId })
|
||||
.then(() => {
|
||||
location.reload()
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setLoading(appId, false)
|
||||
this.setLoading('install', false)
|
||||
this.setError(appId, error.response.data.data.message)
|
||||
this.appsApiFailure({ appId, error })
|
||||
})
|
||||
}).catch(() => {
|
||||
this.setLoading(appId, false)
|
||||
this.setLoading('install', false)
|
||||
})
|
||||
},
|
||||
|
||||
disableApp(appId: string) {
|
||||
this.setLoading(appId, true)
|
||||
return confirmPassword().then(() => {
|
||||
return api.get(generateUrl(`apps/app_api/apps/disable/${appId}`))
|
||||
.then(() => {
|
||||
this.setLoading(appId, false)
|
||||
const app = this.apps.find((app) => app.id === appId)
|
||||
if (app) {
|
||||
app.active = false
|
||||
if (app.removable) {
|
||||
app.canUnInstall = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setLoading(appId, false)
|
||||
this.appsApiFailure({ appId, error })
|
||||
})
|
||||
}).catch(() => {
|
||||
this.setLoading(appId, false)
|
||||
})
|
||||
},
|
||||
|
||||
uninstallApp(appId: string, removeData: boolean) {
|
||||
this.setLoading(appId, true)
|
||||
return confirmPassword().then(() => {
|
||||
return api.get(generateUrl(`/apps/app_api/apps/uninstall/${appId}?removeData=${removeData}`))
|
||||
.then(() => {
|
||||
this.setLoading(appId, false)
|
||||
const app = this.apps.find((app) => app.id === appId)
|
||||
if (app) {
|
||||
app.active = false
|
||||
app.needsDownload = true
|
||||
app.installed = false
|
||||
app.canUnInstall = false
|
||||
app.canInstall = true
|
||||
app.daemon = null
|
||||
app.status = {}
|
||||
if (app.update !== null) {
|
||||
this.updateCount--
|
||||
}
|
||||
app.update = undefined
|
||||
}
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setLoading(appId, false)
|
||||
this.appsApiFailure({ appId, error })
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
updateApp(appId: string) {
|
||||
this.setLoading(appId, true)
|
||||
this.setLoading('install', true)
|
||||
return confirmPassword().then(() => {
|
||||
return api.get(generateUrl(`/apps/app_api/apps/update/${appId}`))
|
||||
.then(() => {
|
||||
this.setLoading(appId, false)
|
||||
this.setLoading('install', false)
|
||||
const app = this.apps.find((app) => app.id === appId)
|
||||
if (app) {
|
||||
const version = app.update
|
||||
app.update = undefined
|
||||
app.version = version || app.version
|
||||
app.status = {
|
||||
type: 'update',
|
||||
action: 'deploy',
|
||||
init: 0,
|
||||
deploy: 0,
|
||||
} as IExAppStatus
|
||||
app.error = ''
|
||||
}
|
||||
this.updateCount--
|
||||
this.updateAppsStatus()
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setLoading(appId, false)
|
||||
this.setLoading('install', false)
|
||||
this.appsApiFailure({ appId, error })
|
||||
})
|
||||
}).catch(() => {
|
||||
this.setLoading(appId, false)
|
||||
this.setLoading('install', false)
|
||||
})
|
||||
},
|
||||
|
||||
async fetchAllApps() {
|
||||
this.loadingList = true
|
||||
try {
|
||||
const response = await api.get(generateUrl('/apps/app_api/apps/list'))
|
||||
this.apps = response.data.apps
|
||||
this.loadingList = false
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(error as string)
|
||||
showError(t('appstore', 'An error occurred during the request. Unable to proceed.'))
|
||||
this.loadingList = false
|
||||
}
|
||||
},
|
||||
|
||||
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()
|
||||
})
|
||||
},
|
||||
|
||||
async fetchDockerDaemons() {
|
||||
try {
|
||||
const { data } = await axios.get(generateUrl('/apps/app_api/daemons'))
|
||||
this.defaultDaemon = data.daemons.find((daemon: IDeployDaemon) => daemon.name === data.default_daemon_config)
|
||||
this.dockerDaemons = data.daemons.filter((daemon: IDeployDaemon) => daemon.accepts_deploy_id === 'docker-install')
|
||||
} catch (error) {
|
||||
logger.error('[app-api-store] Failed to fetch Docker daemons', { error })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
updateAppsStatus() {
|
||||
clearInterval(this.statusUpdater as number)
|
||||
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
|
||||
if (initializingOrDeployingApps.length === 0) {
|
||||
return
|
||||
}
|
||||
this.statusUpdater = setInterval(() => {
|
||||
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
|
||||
logger.debug('initializingOrDeployingApps', { initializingOrDeployingApps })
|
||||
initializingOrDeployingApps.forEach((app) => {
|
||||
this.fetchAppStatus(app.id)
|
||||
})
|
||||
}, 2000) as unknown as number
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { OCSResponse } from '@nextcloud/typings/ocs'
|
||||
import type { IAppstoreApp, IAppstoreCategory } from '../app-types.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { defineStore } from 'pinia'
|
||||
import APPSTORE_CATEGORY_ICONS from '../constants/AppstoreCategoryIcons.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
const showApiError = () => showError(t('appstore', 'An error occurred during the request. Unable to proceed.'))
|
||||
|
||||
export const useAppsStore = defineStore('appstore-apps', {
|
||||
state: () => ({
|
||||
apps: [] as IAppstoreApp[],
|
||||
categories: [] as IAppstoreCategory[],
|
||||
updateCount: loadState<number>('appstore', 'appstoreUpdateCount', 0),
|
||||
loading: {
|
||||
apps: false,
|
||||
categories: false,
|
||||
},
|
||||
loadingList: false,
|
||||
gettingCategoriesPromise: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async loadCategories(force = false) {
|
||||
if (this.categories.length > 0 && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading.categories = true
|
||||
const url = generateOcsUrl('apps/appstore/api/v1/apps/categories')
|
||||
const { data } = await axios.get<OCSResponse<IAppstoreCategory[]>>(url)
|
||||
|
||||
const categories = data.ocs.data
|
||||
for (const category of categories) {
|
||||
category.icon = APPSTORE_CATEGORY_ICONS[category.id] ?? ''
|
||||
}
|
||||
|
||||
this.$patch({
|
||||
categories,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(error as Error)
|
||||
showApiError()
|
||||
} finally {
|
||||
this.loading.categories = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadApps(force = false) {
|
||||
if (this.apps.length > 0 && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading.apps = true
|
||||
const url = generateOcsUrl('apps/appstore/api/v1/apps')
|
||||
const { data } = await axios.get<OCSResponse<IAppstoreApp[]>>(url)
|
||||
|
||||
this.$patch({
|
||||
apps: data.ocs.data,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(error as Error)
|
||||
showApiError()
|
||||
} finally {
|
||||
this.loading.apps = false
|
||||
}
|
||||
},
|
||||
|
||||
getCategoryById(categoryId: string) {
|
||||
return this.categories.find(({ id }) => id === categoryId) ?? null
|
||||
},
|
||||
|
||||
getAppById(appId: string): IAppstoreApp | null {
|
||||
return this.apps.find(({ id }) => id === appId) ?? null
|
||||
},
|
||||
|
||||
updateAppGroups(appId: string, groups: string[]) {
|
||||
const app = this.apps.find(({ id }) => id === appId)
|
||||
if (app) {
|
||||
app.groups = [...groups]
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -1,392 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { showError, showInfo } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { PwdConfirmationMode } from '@nextcloud/password-confirmation'
|
||||
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
|
||||
import Vue from 'vue'
|
||||
import logger from '../utils/logger.ts'
|
||||
import api from './api.js'
|
||||
|
||||
const state = {
|
||||
apps: [],
|
||||
bundles: loadState('appstore', 'appstoreBundles', []),
|
||||
categories: [],
|
||||
updateCount: loadState('appstore', 'appstoreUpdateCount', 0),
|
||||
loading: {},
|
||||
gettingCategoriesPromise: null,
|
||||
appApiEnabled: loadState('appstore', 'appApiEnabled', false),
|
||||
}
|
||||
|
||||
const mutations = {
|
||||
|
||||
APPS_API_FAILURE(state, error) {
|
||||
showError(t('appstore', 'An error occurred during the request. Unable to proceed.') + '<br>' + error.error.response.data.data.message, { isHTML: true })
|
||||
logger.error('An error occurred during the request. Unable to proceed.', { state, error })
|
||||
},
|
||||
|
||||
initCategories(state, { categories, updateCount }) {
|
||||
state.categories = categories
|
||||
state.updateCount = updateCount
|
||||
},
|
||||
|
||||
updateCategories(state, categoriesPromise) {
|
||||
state.gettingCategoriesPromise = categoriesPromise
|
||||
},
|
||||
|
||||
setUpdateCount(state, updateCount) {
|
||||
state.updateCount = updateCount
|
||||
},
|
||||
|
||||
addCategory(state, category) {
|
||||
state.categories.push(category)
|
||||
},
|
||||
|
||||
appendCategories(state, categoriesArray) {
|
||||
// convert obj to array
|
||||
state.categories = categoriesArray
|
||||
},
|
||||
|
||||
setAllApps(state, apps) {
|
||||
state.apps = apps
|
||||
},
|
||||
|
||||
setError(state, { appId, error }) {
|
||||
if (!Array.isArray(appId)) {
|
||||
appId = [appId]
|
||||
}
|
||||
appId.forEach((_id) => {
|
||||
const app = state.apps.find((app) => app.id === _id)
|
||||
app.error = error
|
||||
})
|
||||
},
|
||||
|
||||
clearError(state, { appId }) {
|
||||
const app = state.apps.find((app) => app.id === appId)
|
||||
app.error = null
|
||||
},
|
||||
|
||||
enableApp(state, { appId, groups }) {
|
||||
const app = state.apps.find((app) => app.id === appId)
|
||||
app.active = true
|
||||
Vue.set(app, 'groups', [...groups])
|
||||
if (app.id === 'app_api') {
|
||||
state.appApiEnabled = true
|
||||
}
|
||||
},
|
||||
|
||||
setInstallState(state, { appId, canInstall }) {
|
||||
const app = state.apps.find((app) => app.id === appId)
|
||||
if (app) {
|
||||
app.canInstall = canInstall === true
|
||||
}
|
||||
},
|
||||
|
||||
disableApp(state, appId) {
|
||||
const app = state.apps.find((app) => app.id === appId)
|
||||
app.active = false
|
||||
app.groups = []
|
||||
if (app.removable) {
|
||||
app.canUnInstall = true
|
||||
}
|
||||
if (app.id === 'app_api') {
|
||||
state.appApiEnabled = false
|
||||
}
|
||||
},
|
||||
|
||||
uninstallApp(state, appId) {
|
||||
state.apps.find((app) => app.id === appId).active = false
|
||||
state.apps.find((app) => app.id === appId).groups = []
|
||||
state.apps.find((app) => app.id === appId).needsDownload = true
|
||||
state.apps.find((app) => app.id === appId).installed = false
|
||||
state.apps.find((app) => app.id === appId).canUnInstall = false
|
||||
state.apps.find((app) => app.id === appId).canInstall = true
|
||||
if (appId === 'app_api') {
|
||||
state.appApiEnabled = false
|
||||
}
|
||||
},
|
||||
|
||||
updateApp(state, appId) {
|
||||
const app = state.apps.find((app) => app.id === appId)
|
||||
const version = app.update
|
||||
app.update = null
|
||||
app.version = version
|
||||
state.updateCount--
|
||||
},
|
||||
|
||||
resetApps(state) {
|
||||
state.apps = []
|
||||
},
|
||||
reset(state) {
|
||||
state.apps = []
|
||||
state.categories = []
|
||||
state.updateCount = 0
|
||||
},
|
||||
startLoading(state, id) {
|
||||
if (Array.isArray(id)) {
|
||||
id.forEach((_id) => {
|
||||
Vue.set(state.loading, _id, true)
|
||||
})
|
||||
} else {
|
||||
Vue.set(state.loading, id, true)
|
||||
}
|
||||
},
|
||||
stopLoading(state, id) {
|
||||
if (Array.isArray(id)) {
|
||||
id.forEach((_id) => {
|
||||
Vue.set(state.loading, _id, false)
|
||||
})
|
||||
} else {
|
||||
Vue.set(state.loading, id, false)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const getters = {
|
||||
isAppApiEnabled(state) {
|
||||
return state.appApiEnabled
|
||||
},
|
||||
loading(state) {
|
||||
return function(id) {
|
||||
return state.loading[id]
|
||||
}
|
||||
},
|
||||
getCategories(state) {
|
||||
return state.categories
|
||||
},
|
||||
getAllApps(state) {
|
||||
return state.apps
|
||||
},
|
||||
getAppBundles(state) {
|
||||
return state.bundles
|
||||
},
|
||||
getUpdateCount(state) {
|
||||
return state.updateCount
|
||||
},
|
||||
getCategoryById: (state) => (selectedCategoryId) => {
|
||||
return state.categories.find((category) => category.id === selectedCategoryId)
|
||||
},
|
||||
}
|
||||
|
||||
const actions = {
|
||||
|
||||
enableApp(context, { appId, groups }) {
|
||||
let apps
|
||||
if (Array.isArray(appId)) {
|
||||
apps = appId
|
||||
} else {
|
||||
apps = [appId]
|
||||
}
|
||||
context.commit('startLoading', apps)
|
||||
context.commit('startLoading', 'install')
|
||||
|
||||
const previousState = {}
|
||||
apps.forEach((_appId) => {
|
||||
const app = context.state.apps.find((app) => app.id === _appId)
|
||||
if (app) {
|
||||
previousState[_appId] = {
|
||||
active: app.active,
|
||||
groups: [...(app.groups || [])],
|
||||
}
|
||||
context.commit('enableApp', { appId: _appId, groups })
|
||||
}
|
||||
})
|
||||
|
||||
const url = generateOcsUrl('apps/appstore/api/v1/apps/enable')
|
||||
return Promise.all(apps.map((appId) => api
|
||||
.post(url, { appId, groups }, { confirmPassword: PwdConfirmationMode.Strict })
|
||||
.then((response) => {
|
||||
context.commit('stopLoading', apps)
|
||||
context.commit('stopLoading', 'install')
|
||||
|
||||
// check for server health
|
||||
return axios.get(generateUrl('apps/files/'))
|
||||
.then(() => {
|
||||
if (response.data.update_required) {
|
||||
showInfo(
|
||||
t(
|
||||
'appstore',
|
||||
'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.',
|
||||
),
|
||||
{
|
||||
onClick: () => window.location.reload(),
|
||||
close: false,
|
||||
|
||||
},
|
||||
)
|
||||
setTimeout(function() {
|
||||
location.reload()
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!Array.isArray(appId)) {
|
||||
showError(t('appstore', 'Error: This app cannot be enabled because it makes the server unstable'))
|
||||
context.commit('setError', {
|
||||
appId: apps,
|
||||
error: t('appstore', 'Error: This app cannot be enabled because it makes the server unstable'),
|
||||
})
|
||||
context.dispatch('disableApp', { appId })
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
context.commit('stopLoading', apps)
|
||||
context.commit('stopLoading', 'install')
|
||||
|
||||
apps.forEach((_appId) => {
|
||||
if (previousState[_appId]) {
|
||||
context.commit('enableApp', {
|
||||
appId: _appId,
|
||||
groups: previousState[_appId].groups,
|
||||
})
|
||||
if (!previousState[_appId].active) {
|
||||
context.commit('disableApp', _appId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const message = error.response?.data?.data?.message
|
||||
if (message) {
|
||||
context.commit('setError', {
|
||||
appId: apps,
|
||||
error: message,
|
||||
})
|
||||
context.commit('APPS_API_FAILURE', { appId, error })
|
||||
}
|
||||
})))
|
||||
},
|
||||
forceEnableApp(context, { appId }) {
|
||||
let apps
|
||||
if (Array.isArray(appId)) {
|
||||
apps = appId
|
||||
} else {
|
||||
apps = [appId]
|
||||
}
|
||||
return api.requireAdmin().then(() => {
|
||||
context.commit('startLoading', apps)
|
||||
context.commit('startLoading', 'install')
|
||||
const url = generateOcsUrl('apps/appstore/api/v1/apps/enable')
|
||||
return api.post(url, { appId, force: true }, { confirmPassword: PwdConfirmationMode.Strict })
|
||||
.then(() => {
|
||||
context.commit('setInstallState', { appId, canInstall: true })
|
||||
})
|
||||
.catch((error) => {
|
||||
context.commit('stopLoading', apps)
|
||||
context.commit('stopLoading', 'install')
|
||||
context.commit('setError', {
|
||||
appId: apps,
|
||||
error: error.response.data.data.message,
|
||||
})
|
||||
context.commit('APPS_API_FAILURE', { appId, error })
|
||||
})
|
||||
.finally(() => {
|
||||
context.commit('stopLoading', apps)
|
||||
context.commit('stopLoading', 'install')
|
||||
})
|
||||
}).catch((error) => context.commit('API_FAILURE', { appId, error }))
|
||||
},
|
||||
disableApp(context, { appId }) {
|
||||
let apps
|
||||
if (Array.isArray(appId)) {
|
||||
apps = appId
|
||||
} else {
|
||||
apps = [appId]
|
||||
}
|
||||
return api.requireAdmin().then(() => {
|
||||
context.commit('startLoading', apps)
|
||||
const url = generateOcsUrl('apps/appstore/api/v1/apps/disable')
|
||||
return Promise.all(apps.map((appId) => {
|
||||
return api.post(url, { appId })
|
||||
.then(() => {
|
||||
context.commit('stopLoading', apps)
|
||||
apps.forEach((_appId) => {
|
||||
context.commit('disableApp', _appId)
|
||||
})
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
context.commit('stopLoading', apps)
|
||||
context.commit('APPS_API_FAILURE', { appId, error })
|
||||
})
|
||||
}))
|
||||
}).catch((error) => context.commit('API_FAILURE', { appId, error }))
|
||||
},
|
||||
uninstallApp(context, { appId }) {
|
||||
return api.requireAdmin().then(() => {
|
||||
context.commit('startLoading', appId)
|
||||
const url = generateOcsUrl('apps/appstore/api/v1/apps/uninstall')
|
||||
return api.post(url, { appId })
|
||||
.then(() => {
|
||||
context.commit('stopLoading', appId)
|
||||
context.commit('uninstallApp', appId)
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
context.commit('stopLoading', appId)
|
||||
context.commit('APPS_API_FAILURE', { appId, error })
|
||||
})
|
||||
}).catch((error) => context.commit('API_FAILURE', { appId, error }))
|
||||
},
|
||||
|
||||
updateApp(context, { appId }) {
|
||||
return api.requireAdmin().then(() => {
|
||||
context.commit('startLoading', appId)
|
||||
context.commit('startLoading', 'install')
|
||||
const url = generateOcsUrl('apps/appstore/api/v1/apps/update')
|
||||
return api.post(url, { appId }, { confirmPassword: PwdConfirmationMode.Strict })
|
||||
.then(() => {
|
||||
context.commit('stopLoading', 'install')
|
||||
context.commit('stopLoading', appId)
|
||||
context.commit('updateApp', appId)
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
context.commit('stopLoading', appId)
|
||||
context.commit('stopLoading', 'install')
|
||||
context.commit('APPS_API_FAILURE', { appId, error })
|
||||
})
|
||||
}).catch((error) => context.commit('API_FAILURE', { appId, error }))
|
||||
},
|
||||
|
||||
getAllApps(context) {
|
||||
context.commit('startLoading', 'list')
|
||||
const url = generateOcsUrl('apps/appstore/api/v1/apps')
|
||||
return api.get(url)
|
||||
.then((response) => {
|
||||
const apps = response.data.ocs.data
|
||||
context.commit('setAllApps', apps)
|
||||
context.commit('stopLoading', 'list')
|
||||
return true
|
||||
})
|
||||
.catch((error) => context.commit('API_FAILURE', error))
|
||||
},
|
||||
|
||||
async getCategories(context, { shouldRefetchCategories = false } = {}) {
|
||||
if (shouldRefetchCategories || !context.state.gettingCategoriesPromise) {
|
||||
context.commit('startLoading', 'categories')
|
||||
try {
|
||||
const categoriesPromise = api.get(generateOcsUrl('apps/appstore/api/v1/apps/categories'))
|
||||
context.commit('updateCategories', categoriesPromise)
|
||||
const categoriesPromiseResponse = (await categoriesPromise).data.ocs
|
||||
if (categoriesPromiseResponse.data.length > 0) {
|
||||
context.commit('appendCategories', categoriesPromiseResponse.data)
|
||||
context.commit('stopLoading', 'categories')
|
||||
return true
|
||||
}
|
||||
context.commit('stopLoading', 'categories')
|
||||
return false
|
||||
} catch (error) {
|
||||
context.commit('API_FAILURE', error)
|
||||
}
|
||||
}
|
||||
return context.state.gettingCategoriesPromise
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export default { state, mutations, getters, actions }
|
||||
285
apps/appstore/src/store/apps.ts
Normal file
285
apps/appstore/src/store/apps.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppBundle, IAppstoreApp, IAppstoreCategory, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
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, canLimitToGroups, canUninstall, needForceEnable } from '../utils/appStatus.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
import { useExAppsStore } from './exApps.ts'
|
||||
|
||||
export const useAppsStore = defineStore('apps', () => {
|
||||
const exApps = useExAppsStore()
|
||||
|
||||
/**
|
||||
* All apps available in the appstore
|
||||
*/
|
||||
const appstoreApps = ref<IAppstoreApp[]>([])
|
||||
/**
|
||||
* All app categories available in the appstore
|
||||
*/
|
||||
const categories = ref<IAppstoreCategory[]>([])
|
||||
/**
|
||||
* All app bundles available in the appstore
|
||||
*/
|
||||
const bundles = readonly(loadState<IAppBundle[]>('appstore', 'appstoreBundles'))
|
||||
|
||||
/**
|
||||
* Loading state of the store
|
||||
*/
|
||||
const isLoadingApps = ref(false)
|
||||
const isLoadingCategories = ref(false)
|
||||
|
||||
/**
|
||||
* All apps available
|
||||
*/
|
||||
const apps = computed(() => [...appstoreApps.value, ...(exApps.isEnabled ? exApps.apps : [])])
|
||||
|
||||
/**
|
||||
* Get a category by its id
|
||||
*
|
||||
* @param categoryId - The id of the category
|
||||
*/
|
||||
function getCategoryById(categoryId: string) {
|
||||
return categories.value.find(({ id }) => id === categoryId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an app by its id
|
||||
*
|
||||
* @param appId - The id of the app
|
||||
*/
|
||||
function getAppById(appId: string): IAppstoreApp | IAppstoreExApp | null {
|
||||
return apps.value.find(({ id }) => id === appId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all apps of a category
|
||||
*
|
||||
* @param categoryId - The id of the category
|
||||
*/
|
||||
function getAppsByCategory(categoryId: string): (IAppstoreApp | IAppstoreExApp)[] {
|
||||
return apps.value.filter((app) => [app.category].flat().includes(categoryId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable an app by its id
|
||||
*
|
||||
* @param appId - The app to enable
|
||||
* @param force - Whether to force enable the app
|
||||
*/
|
||||
async function enableApp(appId: string, force = false) {
|
||||
const app = getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
if (app.active || (!app.installed && !canInstall(app))) {
|
||||
throw new Error(`App with id ${appId} cannot be enabled`)
|
||||
}
|
||||
|
||||
if (!force && needForceEnable(app)) {
|
||||
throw new Error(`App with id ${appId} requires force enable`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
if (app.app_api) {
|
||||
await exApps.enableApp(appId)
|
||||
} else {
|
||||
await api.enableApp(appId, force)
|
||||
}
|
||||
if (force) {
|
||||
app.isCompatible = true
|
||||
}
|
||||
app.active = true
|
||||
app.installed = true
|
||||
app.removable = true
|
||||
await rebuildNavigation()
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable an app by its id
|
||||
*
|
||||
* @param appId - The app to disable
|
||||
*/
|
||||
async function disableApp(appId: string) {
|
||||
const app = getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
if (!canDisable(app)) {
|
||||
throw new Error(`App with id ${appId} cannot be disabled`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
if (app.app_api) {
|
||||
await exApps.disableApp(appId)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an app by its id
|
||||
*
|
||||
* @param appId - The app to uninstall
|
||||
*/
|
||||
async function uninstallApp(appId: string) {
|
||||
const app = getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
if (!canUninstall(app)) {
|
||||
throw new Error(`App with id ${appId} cannot be uninstalled`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
if (app.app_api) {
|
||||
await exApps.uninstallApp(appId)
|
||||
} else {
|
||||
await api.uninstallApp(appId)
|
||||
}
|
||||
app.active = false
|
||||
app.installed = false
|
||||
await rebuildNavigation()
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit access to an app to specific groups
|
||||
*
|
||||
* @param appId - The app to limit access to
|
||||
* @param groups - The groups which should have access
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a whole bundle of apps by its id
|
||||
*
|
||||
* @param bundleId - The id of the bundle to enable
|
||||
*/
|
||||
async function enableBundle(bundleId: string) {
|
||||
const bundle = bundles.find((b) => b.id === bundleId)
|
||||
if (!bundle) {
|
||||
throw new Error(`Bundle with id ${bundleId} not found`)
|
||||
}
|
||||
|
||||
try {
|
||||
for (const appId of bundle.appIdentifiers) {
|
||||
const app = getAppById(appId)!
|
||||
app.loading = true
|
||||
}
|
||||
await api.enableBundle(bundle.id)
|
||||
for (const appId of bundle.appIdentifiers) {
|
||||
const app = getAppById(appId)!
|
||||
app.active = true
|
||||
app.installed = true
|
||||
app.removable = true
|
||||
await rebuildNavigation()
|
||||
}
|
||||
} finally {
|
||||
for (const appId of bundle.appIdentifiers) {
|
||||
const app = getAppById(appId)!
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the app categories from the backend
|
||||
*/
|
||||
async function loadCategories() {
|
||||
try {
|
||||
isLoadingCategories.value = true
|
||||
categories.value = await api.getCategories()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load app categories', { error })
|
||||
showError(t('appstore', 'Could not load app categories. Please try again later.'))
|
||||
} finally {
|
||||
isLoadingCategories.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the apps from the backend
|
||||
*/
|
||||
async function loadApps() {
|
||||
try {
|
||||
isLoadingApps.value = true
|
||||
appstoreApps.value = await api.getApps()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load apps list', { error })
|
||||
showError(t('appstore', 'Could not load apps list. Please try again later.'))
|
||||
} finally {
|
||||
isLoadingApps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// initialize store
|
||||
loadApps()
|
||||
loadCategories()
|
||||
|
||||
return {
|
||||
apps,
|
||||
bundles,
|
||||
categories,
|
||||
isLoadingApps,
|
||||
isLoadingCategories,
|
||||
|
||||
disableApp,
|
||||
enableApp,
|
||||
uninstallApp,
|
||||
enableBundle,
|
||||
|
||||
getAppById,
|
||||
getAppsByCategory,
|
||||
getCategoryById,
|
||||
limitAppToGroups,
|
||||
}
|
||||
})
|
||||
327
apps/appstore/src/store/exApps.ts
Normal file
327
apps/appstore/src/store/exApps.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreExApp, IDeployDaemon, IExAppStatus } from '../apps.d.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { spawnDialog } from '@nextcloud/vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import DaemonSelectionDialog from '../components/DaemonSelectionDialog/DaemonSelectionDialog.vue'
|
||||
import * as exAppApi from '../service/exAppApi.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
export const useExAppsStore = defineStore('external-apps', () => {
|
||||
/**
|
||||
* Is the App API enabled
|
||||
*/
|
||||
const isEnabled = loadState('appstore', 'appApiEnabled', false)
|
||||
|
||||
/**
|
||||
* All external apps available
|
||||
*/
|
||||
const apps = ref<IAppstoreExApp[]>([])
|
||||
|
||||
/**
|
||||
* Number of external apps with available updates, used to show the update badge in the UI
|
||||
*/
|
||||
const updateCount = ref(loadState('appstore', 'appstoreExAppUpdateCount', 0))
|
||||
|
||||
/**
|
||||
* 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.type !== ''
|
||||
&& (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck')))
|
||||
|
||||
/**
|
||||
* Get an external app by its ID
|
||||
*
|
||||
* @param appId - The app ID
|
||||
*/
|
||||
function getById(appId: string): IAppstoreExApp | null {
|
||||
return apps.value.find(({ id }) => id === appId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable an exApp.
|
||||
*
|
||||
* @param appId - The app ID
|
||||
*/
|
||||
async function enableApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
if (dockerDaemons.value.length === 1 && app.needsDownload) {
|
||||
exAppApi.enableExApp(app, dockerDaemons[0])
|
||||
app.daemon = dockerDaemons[0]
|
||||
} else if (app.needsDownload) {
|
||||
const daemon = await spawnDialog(DaemonSelectionDialog, { app })
|
||||
if (!daemon) {
|
||||
throw new Error('No daemon selected')
|
||||
}
|
||||
await exAppApi.enableExApp(app, daemon)
|
||||
app.daemon = daemon
|
||||
} else {
|
||||
await exAppApi.enableExApp(app, app.daemon!)
|
||||
}
|
||||
|
||||
if (!app.installed) {
|
||||
app.needsDownload = false
|
||||
app.status = {
|
||||
type: 'install',
|
||||
action: 'deploy',
|
||||
init: 0,
|
||||
deploy: 0,
|
||||
} as IExAppStatus
|
||||
}
|
||||
app.removable = true
|
||||
delete app.error
|
||||
|
||||
await fetchAppStatus(appId)
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force enable an exApp by ignoring its dependencies.
|
||||
*
|
||||
* @param appId - The app to force-enable
|
||||
*/
|
||||
async function forceEnableApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
await exAppApi.forceEnableExApp(appId)
|
||||
await initialize(true)
|
||||
app.active = false
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param appId - The app to disable
|
||||
*/
|
||||
async function disableApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
await exAppApi.disableExApp(appId)
|
||||
app.active = false
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an app by its id
|
||||
*
|
||||
* @param appId - The app to uninstall
|
||||
*/
|
||||
async function uninstallApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
await exAppApi.disableExApp(appId)
|
||||
app.active = false
|
||||
app.needsDownload = true
|
||||
app.installed = false
|
||||
app.daemon = null
|
||||
app.status = {}
|
||||
if (app.update !== null) {
|
||||
updateCount.value--
|
||||
}
|
||||
delete app.update
|
||||
delete app.error
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an external app
|
||||
*
|
||||
* @param appId - The app ID
|
||||
*/
|
||||
async function updateApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
await axios.get(generateUrl(`/apps/app_api/apps/update/${appId}`))
|
||||
app.version = app.update || app.version
|
||||
app.status = {
|
||||
type: 'update',
|
||||
action: 'deploy',
|
||||
init: 0,
|
||||
deploy: 0,
|
||||
} satisfies IExAppStatus
|
||||
delete app.update
|
||||
delete app.error
|
||||
updateCount.value--
|
||||
|
||||
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.'))
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store.
|
||||
* This only needs to be called when an app management operation is performed.
|
||||
*
|
||||
* @param force - If the initialization should be forced (to run again)
|
||||
*/
|
||||
async function initialize(force = false) {
|
||||
if (force || (!defaultDaemon.value || !dockerDaemons.value.length)) {
|
||||
await fetchDockerDaemons()
|
||||
}
|
||||
if (force || apps.value.length === 0) {
|
||||
await fetchAllApps()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
|
||||
apps,
|
||||
updateCount,
|
||||
defaultDaemon,
|
||||
dockerDaemons,
|
||||
daemonAccessible,
|
||||
|
||||
getById,
|
||||
disableApp,
|
||||
enableApp,
|
||||
forceEnableApp,
|
||||
updateApp,
|
||||
uninstallApp,
|
||||
initialize,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the configured docker daemons from the backend.
|
||||
*/
|
||||
async function fetchDockerDaemons() {
|
||||
try {
|
||||
const { data } = await axios.get(generateUrl('/apps/app_api/daemons'))
|
||||
defaultDaemon.value = data.daemons.find((daemon: IDeployDaemon) => daemon.name === data.default_daemon_config)
|
||||
dockerDaemons.value = data.daemons.filter((daemon: IDeployDaemon) => daemon.accepts_deploy_id === 'docker-install')
|
||||
} catch (error) {
|
||||
logger.error('[app-api-store] Failed to fetch Docker daemons', { error })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of external apps from the backend.
|
||||
*/
|
||||
async function fetchAllApps() {
|
||||
try {
|
||||
apps.value = await exAppApi.fetchApps()
|
||||
} catch (error) {
|
||||
logger.error('An error occurred while fetching apps', { error })
|
||||
showError(t('appstore', 'An error occurred during the request. Unable to proceed.'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { Store } from 'vuex'
|
||||
import logger from '../utils/logger.js'
|
||||
import apps from './apps.js'
|
||||
|
||||
const mutations = {
|
||||
API_FAILURE(state, error) {
|
||||
try {
|
||||
const message = error.error.response.data.ocs.meta.message
|
||||
showError(t('appstore', 'An error occurred during the request. Unable to proceed.') + '<br>' + message, { isHTML: true })
|
||||
} catch {
|
||||
showError(t('appstore', 'An error occurred during the request. Unable to proceed.'))
|
||||
}
|
||||
logger.error('An error occurred during the request.', { state, error })
|
||||
},
|
||||
}
|
||||
|
||||
let store = null
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useStore() {
|
||||
if (store === null) {
|
||||
store = new Store({
|
||||
modules: {
|
||||
apps,
|
||||
},
|
||||
strict: !PRODUCTION,
|
||||
mutations,
|
||||
})
|
||||
}
|
||||
return store
|
||||
}
|
||||
63
apps/appstore/src/store/updates.ts
Normal file
63
apps/appstore/src/store/updates.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import * as api from '../service/api.ts'
|
||||
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
import { useAppsStore } from './apps.ts'
|
||||
import { useExAppsStore } from './exApps.ts'
|
||||
|
||||
export const useUpdatesStore = defineStore('updates', () => {
|
||||
const exApps = useExAppsStore()
|
||||
|
||||
/**
|
||||
* Number of apps with available updates
|
||||
*/
|
||||
const internalUpdateCount = ref(loadState<number>('appstore', 'appstoreUpdateCount', 0))
|
||||
|
||||
/**
|
||||
* Total number of apps with available updates
|
||||
*/
|
||||
const updateCount = computed(() => internalUpdateCount.value + exApps.updateCount)
|
||||
|
||||
/**
|
||||
* Update the given app
|
||||
*
|
||||
* @param appId - The app id to update
|
||||
* @throws {Error} if the app is not found
|
||||
*/
|
||||
async function updateApp(appId: string) {
|
||||
const store = useAppsStore()
|
||||
|
||||
const app = store.getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
try {
|
||||
if ('app_api' in app && app.app_api) {
|
||||
await exApps.updateApp(appId)
|
||||
} else {
|
||||
await api.updateApp(appId)
|
||||
internalUpdateCount.value = Math.max(internalUpdateCount.value - 1, 0)
|
||||
}
|
||||
|
||||
rebuildNavigation()
|
||||
} catch (error) {
|
||||
logger.error('Failed to update app', { appId, error })
|
||||
showError(t('appstore', 'Could not update the app. Please try again later.'))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateCount,
|
||||
updateApp,
|
||||
}
|
||||
})
|
||||
66
apps/appstore/src/store/userSettings.ts
Normal file
66
apps/appstore/src/store/userSettings.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
export const useUserSettingsStore = defineStore('userSettings', () => {
|
||||
const defaultGridSize = ref('')
|
||||
|
||||
const isGridView = ref(false)
|
||||
const showIncompatible = ref(true)
|
||||
|
||||
const gridSizePx = computed(() => {
|
||||
if (defaultGridSize.value === 'm') {
|
||||
return '468px'
|
||||
} else if (defaultGridSize.value === 'l') {
|
||||
return '512px'
|
||||
}
|
||||
return '320px'
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the query parameters for the current settings
|
||||
*
|
||||
* @param gridMode Optional override for the grid mode, if not provided it will use the current setting
|
||||
*/
|
||||
function getQuery(gridMode?: boolean) {
|
||||
const route = useRoute() ?? {}
|
||||
return {
|
||||
...route.query,
|
||||
grid: (gridMode ?? isGridView.value) ? (defaultGridSize.value || null) : undefined,
|
||||
compatible: showIncompatible.value ? undefined : null,
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
router.afterEach((to) => {
|
||||
updateFromQuery(to.query)
|
||||
})
|
||||
|
||||
return {
|
||||
defaultGridSize,
|
||||
gridSizePx,
|
||||
|
||||
isGridView,
|
||||
showIncompatible,
|
||||
|
||||
getQuery,
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the store with the current query parameters
|
||||
*
|
||||
* @param query The query parameters to initialize the store with
|
||||
*/
|
||||
function updateFromQuery(query: LocationQuery) {
|
||||
isGridView.value = 'grid' in query
|
||||
defaultGridSize.value = [query.grid ?? ''].flat()[0]!.toLowerCase()
|
||||
showIncompatible.value = !('compatible' in query)
|
||||
}
|
||||
})
|
||||
125
apps/appstore/src/utils/appStatus.ts
Normal file
125
apps/appstore/src/utils/appStatus.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/*!
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
/**
|
||||
* Check if an app can be installed.
|
||||
*
|
||||
* @param app - The app to check if installable
|
||||
*/
|
||||
export function canInstall(app: IAppstoreApp | IAppstoreExApp) {
|
||||
if (app.installed || app.internal) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (app.missingDependencies === undefined || app.missingDependencies.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!app.isCompatible && app.missingDependencies.length === 1) {
|
||||
// incompatible so can be installed but has to be force-enabled
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app can be uninstalled.
|
||||
*
|
||||
* @param app - The app to check if uninstallable
|
||||
*/
|
||||
export function canUninstall(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.installed && app.removable && !app.active
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if app can be enabled.
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
export function canEnable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !isInitializing(app) && !isDeploying(app) && canForceEnable(app) && app.isCompatible
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app can be force-enabled
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
export function canForceEnable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !app.active && (app.installed || canInstall(app))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app needs to be force-enabled
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
export function needForceEnable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !app.active && !app.isCompatible
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app can be disabled.
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
export function canDisable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !isInitializing(app) && !isDeploying(app) && app.active && !app.internal
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app can be updated.
|
||||
*
|
||||
* @param app - The app to check if update-able
|
||||
*/
|
||||
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,33 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { AxiosError } from '@nextcloud/axios'
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
/**
|
||||
* @param error the error
|
||||
* @param message the message to display
|
||||
*/
|
||||
export function handleError(error: AxiosError, message: string) {
|
||||
let fullMessage = ''
|
||||
|
||||
if (message) {
|
||||
fullMessage += message
|
||||
}
|
||||
|
||||
if (error.response?.status === 429) {
|
||||
if (fullMessage) {
|
||||
fullMessage += '\n'
|
||||
}
|
||||
fullMessage += t('appstore', 'There were too many requests from your network. Retry later or contact your administrator if this is an error.')
|
||||
}
|
||||
|
||||
fullMessage = fullMessage || t('appstore', 'Error')
|
||||
showError(fullMessage)
|
||||
logger.error(fullMessage, { error })
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<NcContent app-name="settings">
|
||||
<router-view name="navigation" />
|
||||
<router-view />
|
||||
<router-view name="sidebar" />
|
||||
</NcContent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NcContent from '@nextcloud/vue/components/NcContent'
|
||||
</script>
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<!-- Apps list -->
|
||||
<NcAppContent
|
||||
class="app-settings-content"
|
||||
:page-heading="pageHeading"
|
||||
:page-title="pageTitle">
|
||||
<h2 class="app-settings-content__label" v-text="viewLabel" />
|
||||
|
||||
<AppStoreDiscoverSection v-if="currentCategory === 'discover'" />
|
||||
<NcEmptyContent
|
||||
v-else-if="isLoading"
|
||||
class="empty-content__loading"
|
||||
:name="t('settings', 'Loading app list')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<AppList v-else :category="currentCategory" />
|
||||
</NcAppContent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { computed, getCurrentInstance, onBeforeMount, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router/composables'
|
||||
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppList from '../components/AppList.vue'
|
||||
import AppStoreDiscoverSection from '../components/AppStoreDiscover/AppStoreDiscoverSection.vue'
|
||||
import { APPS_SECTION_ENUM } from '../constants/AppsConstants.js'
|
||||
import { useAppApiStore } from '../store/app-api-store.ts'
|
||||
import { useAppsStore } from '../store/apps-store.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useAppsStore()
|
||||
const appApiStore = useAppApiStore()
|
||||
|
||||
/**
|
||||
* ID of the current active category, default is `discover`
|
||||
*/
|
||||
const currentCategory = computed(() => route.params?.category ?? 'discover')
|
||||
|
||||
const viewLabel = computed<string>(() => APPS_SECTION_ENUM[currentCategory.value] ?? store.getCategoryById(currentCategory.value)?.displayName)
|
||||
|
||||
const pageHeading = t('settings', 'App Store')
|
||||
const pageTitle = computed(() => `${viewLabel.value} - ${pageHeading}`) // NcAppContent automatically appends the instance name
|
||||
|
||||
// TODO this part should be migrated to pinia
|
||||
const instance = getCurrentInstance()
|
||||
/** Is the app list loading */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isLoading = computed(() => (instance?.proxy as any).$store.getters.loading('list'))
|
||||
onBeforeMount(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(instance?.proxy as any).$store.dispatch('getCategories', { shouldRefetchCategories: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(instance?.proxy as any).$store.dispatch('getAllApps')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((instance?.proxy as any).$store.getters.isAppApiEnabled) {
|
||||
appApiStore.fetchAllApps()
|
||||
appApiStore.updateAppsStatus()
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(appApiStore.getStatusUpdater)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-content__loading {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-settings-content__label {
|
||||
margin-block-start: var(--app-navigation-padding);
|
||||
margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
|
||||
min-height: var(--default-clickable-area);
|
||||
line-height: var(--default-clickable-area);
|
||||
vertical-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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>
|
||||
74
apps/appstore/src/views/AppstoreBrowse.vue
Normal file
74
apps/appstore/src/views/AppstoreBrowse.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppGrid from '../components/AppGrid/AppGrid.vue'
|
||||
import OfficeSuiteSwitcher from '../components/AppstoreBrowse/OfficeSuiteSwitcher.vue'
|
||||
import AppTable from '../components/AppTable/AppTable.vue'
|
||||
import AppToolbar from '../components/AppToolbar.vue'
|
||||
import { useFilteredApps } from '../composables/useFilteredApps.ts'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { useUserSettingsStore } from '../store/userSettings.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useAppsStore()
|
||||
const userSettings = useUserSettingsStore()
|
||||
|
||||
const currentCategory = computed(() => route.params!.category as string)
|
||||
const apps = computed(() => {
|
||||
if (currentCategory.value === 'featured') {
|
||||
return store.apps.filter((app) => app.level === 200)
|
||||
} else if (currentCategory.value === 'supported') {
|
||||
return store.apps.filter((app) => app.level === 300)
|
||||
}
|
||||
return store.getAppsByCategory(currentCategory.value)
|
||||
})
|
||||
|
||||
const visibleApps = useFilteredApps(apps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppToolbar />
|
||||
|
||||
<!-- Apps list -->
|
||||
<NcEmptyContent
|
||||
v-if="store.isLoadingApps"
|
||||
:name="t('appstore', 'Loading app list')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<template v-else>
|
||||
<OfficeSuiteSwitcher v-if="currentCategory === 'office'" />
|
||||
|
||||
<component
|
||||
:is="userSettings.isGridView ? AppGrid : AppTable"
|
||||
v-if="visibleApps.length"
|
||||
:class="$style.appstoreBrowse"
|
||||
:apps="visibleApps" />
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
:name="t('appstore', 'No matching apps found')">
|
||||
<template #action>
|
||||
<NcButton variant="primary" @click="$router.push({ query: $route.query, name: 'apps-search' })">
|
||||
{{ t('appstore', 'Search everywhere') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreBrowse {
|
||||
margin-bottom: var(--body-container-margin);
|
||||
}
|
||||
</style>
|
||||
112
apps/appstore/src/views/AppstoreBundles.vue
Normal file
112
apps/appstore/src/views/AppstoreBundles.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppBundle, IAppstoreApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiDownloadMultiple } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppTable from '../components/AppTable/AppTable.vue'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canEnable } from '../utils/appStatus.ts'
|
||||
|
||||
const store = useAppsStore()
|
||||
|
||||
const appBundles = computed(() => store.bundles.map((bundle) => ({
|
||||
...bundle,
|
||||
apps: bundle.appIdentifiers
|
||||
.map((id) => store.apps.find((app) => app.id === id))
|
||||
.filter(Boolean) as IAppstoreApp[],
|
||||
isEnabling: false,
|
||||
})))
|
||||
|
||||
/**
|
||||
* Check if a bundle can be enabled
|
||||
*
|
||||
* @param bundle - The bundle to check
|
||||
*/
|
||||
function canEnableBundle(bundle: IAppBundle): boolean {
|
||||
return bundle.appIdentifiers.every((id) => {
|
||||
const app = store.apps.find((app) => app.id === id)
|
||||
return app && (app.active || canEnable(app))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bundle is enabled
|
||||
*
|
||||
* @param bundle - The bundle to check
|
||||
*/
|
||||
function isBundleEnabled(bundle: IAppBundle): boolean {
|
||||
return bundle.appIdentifiers.every((id) => {
|
||||
const app = store.apps.find((app) => app.id === id)
|
||||
return app && app.active
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable all apps in a bundle
|
||||
*
|
||||
* @param bundle - The bundle to enable all apps
|
||||
*/
|
||||
async function enableAll(bundle: typeof appBundles.value[number]) {
|
||||
bundle.isEnabling = true
|
||||
await store.enableBundle(bundle.id)
|
||||
bundle.isEnabling = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Apps list -->
|
||||
<NcEmptyContent
|
||||
v-if="store.isLoadingApps"
|
||||
:name="t('appstore', 'Loading app list')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<template v-else>
|
||||
<section v-for="bundle of appBundles" :key="bundle.id">
|
||||
<div :class="$style.appstoreBundles__header">
|
||||
<h3>{{ bundle.name }}</h3>
|
||||
<NcButton
|
||||
v-if="!isBundleEnabled(bundle)"
|
||||
:disabled="!canEnableBundle(bundle)"
|
||||
variant="primary"
|
||||
@click="enableAll(bundle)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiDownloadMultiple" />
|
||||
</template>
|
||||
{{ t('appstore', 'Download and enable all') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<AppTable
|
||||
:class="$style.appstoreBundles__appTable"
|
||||
:apps="bundle.apps" />
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreBundles__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--default-clickable-area);
|
||||
padding-inline: var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
.appstoreBundles__appTable:last-of-type {
|
||||
margin-bottom: var(--body-container-margin);
|
||||
}
|
||||
</style>
|
||||
98
apps/appstore/src/views/AppstoreDiscover.vue
Normal file
98
apps/appstore/src/views/AppstoreDiscover.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppDiscoverElements } from '../apps-discover.d.ts'
|
||||
|
||||
import { mdiEyeOffOutline } from '@mdi/js'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import { getDiscoverElements } from '../service/app-discover.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
const PostType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypePost.vue'))
|
||||
const CarouselType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypeCarousel.vue'))
|
||||
const ShowcaseType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypeShowcase.vue'))
|
||||
|
||||
const hasError = ref(false)
|
||||
const elements = ref<IAppDiscoverElements[]>([])
|
||||
|
||||
/**
|
||||
* Load the app discover section information
|
||||
*/
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
// Set the elements to the UI
|
||||
elements.value = await getDiscoverElements()
|
||||
} catch (error) {
|
||||
hasError.value = true
|
||||
logger.error(error as Error)
|
||||
showError(t('appstore', 'Could not load app discover section'))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the component for the given type
|
||||
*
|
||||
* @param type - The type of the component
|
||||
*/
|
||||
function getComponent(type: IAppDiscoverElements['type']) {
|
||||
if (type === 'post') {
|
||||
return PostType
|
||||
} else if (type === 'carousel') {
|
||||
return CarouselType
|
||||
} else if (type === 'showcase') {
|
||||
return ShowcaseType
|
||||
}
|
||||
return defineComponent({
|
||||
mounted: () => logger.error('Unknown component requested ', type),
|
||||
render: (h) => h('div', t('appstore', 'Could not render element')),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcEmptyContent
|
||||
v-if="hasError"
|
||||
:name="t('appstore', 'Nothing to show')"
|
||||
:description="t('appstore', 'Could not load section content from app store.')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiEyeOffOutline" :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<NcEmptyContent
|
||||
v-else-if="elements.length === 0"
|
||||
:name="t('appstore', 'Loading')"
|
||||
:description="t('appstore', 'Fetching the latest news…')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<div v-else class="app-discover">
|
||||
<component
|
||||
:is="getComponent(entry.type)"
|
||||
v-for="entry, index in elements"
|
||||
:key="entry.id ?? index"
|
||||
v-bind="entry" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-discover {
|
||||
max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */
|
||||
margin-inline: auto;
|
||||
padding-inline: 54px;
|
||||
/* Padding required to make last element not bound to the bottom */
|
||||
padding-block-end: var(--default-clickable-area, 44px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--default-clickable-area, 44px);
|
||||
}
|
||||
</style>
|
||||
72
apps/appstore/src/views/AppstoreManage.vue
Normal file
72
apps/appstore/src/views/AppstoreManage.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppGrid from '../components/AppGrid/AppGrid.vue'
|
||||
import AppTable from '../components/AppTable/AppTable.vue'
|
||||
import AppToolbar from '../components/AppToolbar.vue'
|
||||
import { useFilteredApps } from '../composables/useFilteredApps.ts'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { useUserSettingsStore } from '../store/userSettings.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useAppsStore()
|
||||
const userSettings = useUserSettingsStore()
|
||||
|
||||
const currentCategory = computed(() => route.params!.category as 'enabled' | 'installed' | 'disabled' | 'updates')
|
||||
const apps = computed(() => {
|
||||
if (currentCategory.value === 'installed') {
|
||||
return store.apps.filter((app) => app.installed)
|
||||
} else if (currentCategory.value === 'enabled') {
|
||||
return store.apps.filter((app) => app.active)
|
||||
} else if (currentCategory.value === 'disabled') {
|
||||
return store.apps.filter((app) => app.installed && !app.active)
|
||||
} else if (currentCategory.value === 'updates') {
|
||||
return store.apps.filter((app) => app.update)
|
||||
}
|
||||
return []
|
||||
})
|
||||
const visibleApps = useFilteredApps(apps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppToolbar />
|
||||
|
||||
<!-- Apps list -->
|
||||
<NcEmptyContent
|
||||
v-if="store.isLoadingApps"
|
||||
:name="t('appstore', 'Loading app list')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<component
|
||||
:is="userSettings.isGridView ? AppGrid : AppTable"
|
||||
v-else-if="visibleApps.length"
|
||||
:class="$style.appstoreManage"
|
||||
:apps="visibleApps" />
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
:name="t('appstore', 'No matching apps found')">
|
||||
<template #action>
|
||||
<NcButton variant="primary" @click="$router.push({ query: $route.query, name: 'apps-search' })">
|
||||
{{ t('appstore', 'Search everywhere') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreManage {
|
||||
margin-bottom: var(--body-container-margin);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,59 +2,143 @@
|
|||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useHotKey } from '@nextcloud/vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
|
||||
import NcAppNavigationSpacer from '@nextcloud/vue/components/NcAppNavigationSpacer'
|
||||
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import { APPSTORE_CATEGORY_ICONS, APPSTORE_CATEGORY_NAMES } from '../constants.ts'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { useUpdatesStore } from '../store/updates.ts'
|
||||
import { useUserSettingsStore } from '../store/userSettings.ts'
|
||||
|
||||
const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
|
||||
|
||||
const store = useAppsStore()
|
||||
const updateStore = useUpdatesStore()
|
||||
const userSettings = useUserSettingsStore()
|
||||
const categories = computed(() => store.categories)
|
||||
const categoriesLoading = computed(() => store.isLoadingCategories)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const searchElement = useTemplateRef('search')
|
||||
|
||||
useHotKey('f', () => {
|
||||
if (!searchElement.value?.$refs.inputElement) {
|
||||
emit('toggle-navigation', {
|
||||
open: true,
|
||||
})
|
||||
// open animation
|
||||
window.setTimeout(() => searchElement.value?.$refs.inputElement?.focus(), 400)
|
||||
}
|
||||
searchElement.value?.$refs.inputElement?.focus()
|
||||
}, { ctrl: true, stop: true, prevent: true })
|
||||
|
||||
const search = ref('')
|
||||
// initialize the search value from the query parameter on mount
|
||||
watch(() => route.query.q, (newQuery) => {
|
||||
search.value = [newQuery || ''].flat()[0]!
|
||||
}, { immediate: true })
|
||||
// update the query parameter when the search value changes, debounced to avoid excessive updates
|
||||
watchDebounced(search, (newValue, oldValue) => {
|
||||
if (newValue.trim() === oldValue.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (router.currentRoute.value.name === 'apps-discover' || (router.currentRoute.value.name === 'apps-manage' && route.params.category === 'bundles')) {
|
||||
router.push({
|
||||
name: 'apps-search',
|
||||
query: {
|
||||
...route.query,
|
||||
q: newValue.trim() || undefined,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
q: newValue.trim() || undefined,
|
||||
},
|
||||
})
|
||||
}, { debounce: 500 })
|
||||
|
||||
/**
|
||||
* Check if the current instance has a support subscription from the Nextcloud GmbH
|
||||
*
|
||||
* For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
|
||||
*/
|
||||
const isSubscribed = computed(() => store.apps.find(({ level }) => level === 300) !== undefined)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Categories & filters -->
|
||||
<NcAppNavigation :aria-label="t('settings', 'Apps')">
|
||||
<NcAppNavigation :aria-label="t('appstore', 'Appstore categories')">
|
||||
<template #search>
|
||||
<NcAppNavigationSearch
|
||||
ref="search"
|
||||
v-model="search"
|
||||
:label="t('appstore', 'Search apps…')" />
|
||||
</template>
|
||||
<template #list>
|
||||
<NcAppNavigationItem
|
||||
v-if="appstoreEnabled"
|
||||
id="app-category-discover"
|
||||
:to="{ name: 'apps-category', params: { category: 'discover' } }"
|
||||
:name="APPS_SECTION_ENUM.discover">
|
||||
:to="{ name: 'apps-discover' }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.discover">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.discover" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
id="app-category-installed"
|
||||
:to="{ name: 'apps-category', params: { category: 'installed' } }"
|
||||
:name="APPS_SECTION_ENUM.installed">
|
||||
:to="{ name: 'apps-manage', params: { category: 'installed' } }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.installed">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.installed" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
id="app-category-enabled"
|
||||
:to="{ name: 'apps-category', params: { category: 'enabled' } }"
|
||||
:name="APPS_SECTION_ENUM.enabled">
|
||||
:to="{ name: 'apps-manage', params: { category: 'enabled' } }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.enabled">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.enabled" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
id="app-category-disabled"
|
||||
:to="{ name: 'apps-category', params: { category: 'disabled' } }"
|
||||
:name="APPS_SECTION_ENUM.disabled">
|
||||
:to="{ name: 'apps-manage', params: { category: 'disabled' } }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.disabled">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.disabled" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
v-if="store.updateCount > 0"
|
||||
id="app-category-updates"
|
||||
:to="{ name: 'apps-category', params: { category: 'updates' } }"
|
||||
:name="APPS_SECTION_ENUM.updates">
|
||||
v-if="updateStore.updateCount > 0"
|
||||
:to="{ name: 'apps-manage', params: { category: 'updates' } }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.updates">
|
||||
<template #counter>
|
||||
<NcCounterBubble>{{ store.updateCount }}</NcCounterBubble>
|
||||
<NcCounterBubble :count="updateStore.updateCount" />
|
||||
</template>
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.updates" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
id="app-category-your-bundles"
|
||||
:to="{ name: 'apps-category', params: { category: 'app-bundles' } }"
|
||||
:name="APPS_SECTION_ENUM['app-bundles']">
|
||||
:to="{ name: 'apps-bundles' }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.bundles">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.bundles" />
|
||||
</template>
|
||||
|
|
@ -63,23 +147,24 @@
|
|||
<NcAppNavigationSpacer />
|
||||
|
||||
<!-- App store categories -->
|
||||
<li v-if="appstoreEnabled && categoriesLoading" class="categories--loading">
|
||||
<NcLoadingIcon :size="20" :aria-label="t('settings', 'Loading categories')" />
|
||||
<li v-if="appstoreEnabled && categoriesLoading" :class="$style.appstoreNavigation__categories_loading">
|
||||
<NcLoadingIcon :size="20" :name="t('appstore', 'Loading categories')" />
|
||||
</li>
|
||||
|
||||
<template v-else-if="appstoreEnabled && !categoriesLoading">
|
||||
<NcAppNavigationItem
|
||||
v-if="isSubscribed"
|
||||
id="app-category-supported"
|
||||
:to="{ name: 'apps-category', params: { category: 'supported' } }"
|
||||
:name="APPS_SECTION_ENUM.supported">
|
||||
:to="{ name: 'apps-category', params: { category: 'supported' }, query: userSettings.getQuery(true) }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.supported">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.supported" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
id="app-category-featured"
|
||||
:to="{ name: 'apps-category', params: { category: 'featured' } }"
|
||||
:name="APPS_SECTION_ENUM.featured">
|
||||
:to="{ name: 'apps-category', params: { category: 'featured' }, query: userSettings.getQuery(true) }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.featured">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.featured" />
|
||||
</template>
|
||||
|
|
@ -93,6 +178,7 @@
|
|||
:to="{
|
||||
name: 'apps-category',
|
||||
params: { category: category.id },
|
||||
query: userSettings.getQuery(true),
|
||||
}">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="category.icon" />
|
||||
|
|
@ -103,43 +189,8 @@
|
|||
</NcAppNavigation>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { computed, onBeforeMount } from 'vue'
|
||||
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcAppNavigationSpacer from '@nextcloud/vue/components/NcAppNavigationSpacer'
|
||||
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import { APPS_SECTION_ENUM } from '../constants/AppsConstants.js'
|
||||
import APPSTORE_CATEGORY_ICONS from '../constants/AppstoreCategoryIcons.ts'
|
||||
import { useAppsStore } from '../store/apps-store.ts'
|
||||
|
||||
const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
|
||||
|
||||
const store = useAppsStore()
|
||||
const categories = computed(() => store.categories)
|
||||
const categoriesLoading = computed(() => store.loading.categories)
|
||||
|
||||
/**
|
||||
* Check if the current instance has a support subscription from the Nextcloud GmbH
|
||||
*
|
||||
* For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
|
||||
*/
|
||||
const isSubscribed = computed(() => store.apps.find(({ level }) => level === 300) !== undefined)
|
||||
|
||||
// load categories when component is mounted
|
||||
onBeforeMount(() => {
|
||||
store.loadCategories()
|
||||
store.loadApps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* The categories-loading indicator */
|
||||
.categories--loading {
|
||||
<style module>
|
||||
.appstoreNavigation__categories_loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
75
apps/appstore/src/views/AppstoreSearch.vue
Normal file
75
apps/appstore/src/views/AppstoreSearch.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcInputField from '@nextcloud/vue/components/NcInputField'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppGrid from '../components/AppGrid/AppGrid.vue'
|
||||
import AppTable from '../components/AppTable/AppTable.vue'
|
||||
import AppToolbar from '../components/AppToolbar.vue'
|
||||
import { useFilteredApps } from '../composables/useFilteredApps.ts'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { useUserSettingsStore } from '../store/userSettings.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppsStore()
|
||||
const userSettings = useUserSettingsStore()
|
||||
|
||||
const visibleApps = useFilteredApps(() => store.apps)
|
||||
const search = ref('')
|
||||
|
||||
watch(() => route.query.q, (newQuery) => {
|
||||
search.value = [newQuery || ''].flat()[0]!
|
||||
}, { immediate: true })
|
||||
|
||||
watchDebounced(search, (newValue) => {
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
q: newValue.trim(),
|
||||
},
|
||||
})
|
||||
}, { debounce: 500 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppToolbar />
|
||||
|
||||
<!-- Apps list -->
|
||||
<NcEmptyContent
|
||||
v-if="store.isLoadingApps"
|
||||
:name="t('appstore', 'Loading app list')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<component
|
||||
:is="userSettings.isGridView ? AppGrid : AppTable"
|
||||
v-else-if="visibleApps.length && search.trim().length > 2"
|
||||
:class="$style.appstoreSearch"
|
||||
:apps="visibleApps" />
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
:name="t('appstore', 'No matching apps found')"
|
||||
:description="search.trim().length <= 2 ? t('appstore', 'Please enter more characters to search.') : undefined">
|
||||
<template #action>
|
||||
<NcInputField v-model="search" type="search" :label="t('appstore', 'Search apps')" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreSearch {
|
||||
margin-bottom: var(--body-container-margin);
|
||||
}
|
||||
</style>
|
||||
140
apps/appstore/src/views/AppstoreSidebar.vue
Normal file
140
apps/appstore/src/views/AppstoreSidebar.vue
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<!--
|
||||
- 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'
|
||||
import { useUserSettingsStore } from '../store/userSettings.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppsStore()
|
||||
const userSettings = useUserSettingsStore()
|
||||
|
||||
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,
|
||||
},
|
||||
query: userSettings.getQuery(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
|
@ -24,7 +24,7 @@ use PHPUnit\Framework\MockObject\MockObject;
|
|||
use Psr\Log\LoggerInterface;
|
||||
use Test\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\Group('DB')]
|
||||
#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
|
||||
final class ApiControllerTest extends TestCase {
|
||||
private IRequest&MockObject $request;
|
||||
|
||||
|
|
@ -52,6 +52,7 @@ final class ApiControllerTest extends TestCase {
|
|||
|
||||
private ApiController $apiController;
|
||||
|
||||
#[\Override]
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ use OCP\IURLGenerator;
|
|||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Test\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\Group('DB')]
|
||||
#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
|
||||
final class PageControllerTest extends TestCase {
|
||||
private IRequest&MockObject $request;
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ final class PageControllerTest extends TestCase {
|
|||
|
||||
private PageController $pageController;
|
||||
|
||||
#[\Override]
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue