Merge pull request #57290 from nextcloud/refactor/split-appstore

refactor(appstore): migrate to Typescript and Vue 3
This commit is contained in:
Ferdinand Thiessen 2026-05-06 00:28:15 +02:00 committed by GitHub
commit 387b40bcfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
629 changed files with 6057 additions and 6346 deletions

View file

@ -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,

View file

@ -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"

View 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>

View 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)
},
}

View 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)
},
}

View 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)
},
}

View 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)
},
}

View 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)
},
}

View 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!
},
},
]

View 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 })
},
}

View 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)
},
}

View 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)
},
}

View 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)

View file

@ -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[]
}

View file

@ -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
View 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[]
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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),
}
},

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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);

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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

View 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>

View file

@ -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()
})
})

View file

@ -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>

View 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
![](http://example.com/image.jpg "Title")`,
},
})
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
})
})

View 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>

View 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)!)) : [])
}

View file

@ -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"')

View 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
})
})
}

View file

@ -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
}

View 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('![alt text](http://example.com/image.jpg)')
expect(rendered.value).toMatchInlineSnapshot('"<p>alt text</p>\n"')
})
test('renders images with title', () => {
const rendered = useMarkdown('![](http://example.com/image.jpg "Title")')
expect(rendered.value).toMatchInlineSnapshot('"<p>Title</p>\n"')
})
test('renders images with alt text and title', () => {
const rendered = useMarkdown('![alt text](http://example.com/image.jpg "Title")')
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>"')
})

View 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}>`
}
}

View file

@ -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

View file

@ -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
})

View file

@ -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')

View file

@ -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)
})
}
},
},
}

View file

@ -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,
})

View file

@ -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,
},
],
},

View 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 })
})
}

View 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
}

View 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}`))
}

View file

@ -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,
}

View file

@ -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 })
},
}

View file

@ -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
},
},
})

View file

@ -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]
}
},
},
})

View file

@ -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 }

View 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,
}
})

View 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)
}
})

View 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,
}
})

View file

@ -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
}

View 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,
}
})

View 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)
}
})

View 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'
}

View file

@ -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 })
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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;

View 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>

View 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>

View file

@ -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();

View file

@ -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