Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>pull/57290/head
parent
3b1ed578c5
commit
5e7f45ace6
@ -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) |
||||
}, |
||||
} |
||||
@ -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) |
||||
}, |
||||
} |
||||
@ -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) |
||||
}, |
||||
} |
||||
@ -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) |
||||
}, |
||||
} |
||||
@ -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) |
||||
}, |
||||
} |
||||
@ -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! |
||||
}, |
||||
}, |
||||
] |
||||
@ -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 }) |
||||
}, |
||||
} |
||||
@ -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) |
||||
}, |
||||
} |
||||
@ -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) |
||||
}, |
||||
} |
||||
@ -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) |
||||
@ -0,0 +1,103 @@ |
||||
<!-- |
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
- SPDX-License-Identifier: AGPL-3.0-or-later |
||||
--> |
||||
|
||||
<script setup lang="ts"> |
||||
import type { AppAction } from '../actions/index.ts' |
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' |
||||
|
||||
import { computed } from 'vue' |
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton' |
||||
import NcActionLink from '@nextcloud/vue/components/NcActionLink' |
||||
import NcActionRouter from '@nextcloud/vue/components/NcActionRouter' |
||||
import NcActions from '@nextcloud/vue/components/NcActions' |
||||
import NcButton from '@nextcloud/vue/components/NcButton' |
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' |
||||
|
||||
const { actions, maxInlineActions = 1 } = defineProps<{ |
||||
app: IAppstoreApp | IAppstoreExApp |
||||
actions: AppAction[] |
||||
maxInlineActions?: number |
||||
iconOnly?: boolean |
||||
}>() |
||||
|
||||
const inlineActions = computed(() => { |
||||
if (actions.length <= maxInlineActions) { |
||||
return actions |
||||
} |
||||
return actions |
||||
.filter((action) => action.inline !== false) |
||||
.slice(0, maxInlineActions) |
||||
}) |
||||
|
||||
const menuActions = computed(() => actions |
||||
.filter((action) => !inlineActions.value.includes(action))) |
||||
</script> |
||||
|
||||
<template> |
||||
<div :class="$style.appActions"> |
||||
<NcButton |
||||
v-for="action in inlineActions" |
||||
:key="action.id" |
||||
:ariaLabel="iconOnly ? action.label(app) : undefined" |
||||
:title="iconOnly ? action.label(app) : undefined" |
||||
:variant="action.variant" |
||||
:href="'href' in action ? action.href(app) : undefined" |
||||
:to="'to' in action ? action.to(app) : undefined" |
||||
:target="'href' in action ? '_blank' : undefined" |
||||
@click="'callback' in action && action.callback(app)"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="action.icon" /> |
||||
</template> |
||||
<template v-if="!iconOnly" #default> |
||||
{{ action.label(app) }} |
||||
</template> |
||||
</NcButton> |
||||
<NcActions forceMenu> |
||||
<template v-for="action in menuActions"> |
||||
<NcActionButton |
||||
v-if="'callback' in action" |
||||
:key="'callback-' + action.id" |
||||
closeAfterClick |
||||
:variant="action.variant" |
||||
@click="action.callback(app)"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="action.icon" /> |
||||
</template> |
||||
{{ action.label(app) }} |
||||
</NcActionButton> |
||||
<NcActionLink |
||||
v-else-if="'href' in action" |
||||
:key="'link-' + action.id" |
||||
closeAfterClick |
||||
:variant="action.variant" |
||||
:href="action.href(app)"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="action.icon" /> |
||||
</template> |
||||
{{ action.label(app) }} |
||||
</NcActionLink> |
||||
<NcActionRouter |
||||
v-else |
||||
:key="'route-' + action.id" |
||||
closeAfterClick |
||||
:variant="action.variant" |
||||
:to="action.to(app)"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="action.icon" /> |
||||
</template> |
||||
{{ action.label(app) }} |
||||
</NcActionRouter> |
||||
</template> |
||||
</NcActions> |
||||
</div> |
||||
</template> |
||||
|
||||
<style module> |
||||
.appActions { |
||||
display: flex; |
||||
flex-direction: row; |
||||
gap: calc(2 * var(--default-grid-baseline)); |
||||
} |
||||
</style> |
||||
@ -1,519 +0,0 @@ |
||||
<!-- |
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors |
||||
- SPDX-License-Identifier: AGPL-3.0-or-later |
||||
--> |
||||
|
||||
<template> |
||||
<NcAppSidebarTab |
||||
id="details" |
||||
:name="t('appstore', 'Details')" |
||||
:order="1"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="mdiTextBoxOutline" /> |
||||
</template> |
||||
<div class="app-details"> |
||||
<div class="app-details__actions"> |
||||
<div v-if="app.active && canLimitToGroups(app)" class="app-details__actions-groups"> |
||||
<input |
||||
:id="`groups_enable_${app.id}`" |
||||
v-model="groupCheckedAppsData" |
||||
type="checkbox" |
||||
:value="app.id" |
||||
class="groups-enable__checkbox checkbox" |
||||
@change="setGroupLimit"> |
||||
<label :for="`groups_enable_${app.id}`">{{ t('appstore', 'Limit to groups') }}</label> |
||||
<input |
||||
type="hidden" |
||||
class="group_select" |
||||
:title="t('appstore', 'All')" |
||||
value=""> |
||||
<br> |
||||
<label for="limitToGroups"> |
||||
<span>{{ t('appstore', 'Limit app usage to groups') }}</span> |
||||
</label> |
||||
<NcSelect |
||||
v-if="isLimitedToGroups(app)" |
||||
input-id="limitToGroups" |
||||
:options="groups" |
||||
:model-value="appGroups" |
||||
:limit="5" |
||||
label="name" |
||||
:multiple="true" |
||||
keep-open |
||||
@option:selected="addGroupLimitation" |
||||
@option:deselected="removeGroupLimitation" |
||||
@search="asyncFindGroup"> |
||||
<span slot="noResult">{{ t('appstore', 'No results') }}</span> |
||||
</NcSelect> |
||||
</div> |
||||
<div class="app-details__actions-manage"> |
||||
<input |
||||
v-if="app.update" |
||||
class="update primary" |
||||
type="button" |
||||
:value="t('appstore', 'Update to {version}', { version: app.update })" |
||||
:disabled="installing || isLoading || isManualInstall" |
||||
@click="update(app.id)"> |
||||
<input |
||||
v-if="app.canUnInstall" |
||||
class="uninstall" |
||||
type="button" |
||||
:value="t('appstore', 'Remove')" |
||||
:disabled="installing || isLoading" |
||||
@click="remove(app.id, removeData)"> |
||||
<input |
||||
v-if="app.active" |
||||
class="enable" |
||||
type="button" |
||||
:value="disableButtonText" |
||||
:disabled="installing || isLoading || isInitializing || isDeploying" |
||||
@click="disable(app.id)"> |
||||
<input |
||||
v-if="!app.active && (app.canInstall || app.isCompatible)" |
||||
:title="enableButtonTooltip" |
||||
:aria-label="enableButtonTooltip" |
||||
class="enable primary" |
||||
type="button" |
||||
:value="enableButtonText" |
||||
:disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying" |
||||
@click="enableButtonAction"> |
||||
<input |
||||
v-else-if="!app.active && !app.canInstall" |
||||
:title="forceEnableButtonTooltip" |
||||
:aria-label="forceEnableButtonTooltip" |
||||
class="enable force" |
||||
type="button" |
||||
:value="forceEnableButtonText" |
||||
:disabled="installing || isLoading" |
||||
@click="forceEnable(app.id)"> |
||||
<NcButton |
||||
v-if="app?.app_api && (app.canInstall || app.isCompatible)" |
||||
:aria-label="t('appstore', 'Advanced deploy options')" |
||||
variant="secondary" |
||||
@click="() => showDeployOptionsModal = true"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="mdiToyBrickPlusOutline" /> |
||||
</template> |
||||
{{ t('appstore', 'Deploy options') }} |
||||
</NcButton> |
||||
</div> |
||||
<p v-if="!defaultDeployDaemonAccessible" class="warning"> |
||||
{{ t('appstore', 'Default Deploy daemon is not accessible') }} |
||||
</p> |
||||
<NcCheckboxRadioSwitch |
||||
v-if="app.canUnInstall" |
||||
:model-value="removeData" |
||||
:disabled="installing || isLoading || !defaultDeployDaemonAccessible" |
||||
@update:modelValue="toggleRemoveData"> |
||||
{{ t('appstore', 'Delete data on remove') }} |
||||
</NcCheckboxRadioSwitch> |
||||
</div> |
||||
|
||||
<ul class="app-details__dependencies"> |
||||
<li v-if="app.missingMinOwnCloudVersion"> |
||||
{{ t('appstore', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }} |
||||
</li> |
||||
<li v-if="app.missingMaxOwnCloudVersion"> |
||||
{{ t('appstore', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }} |
||||
</li> |
||||
<li v-if="!app.canInstall"> |
||||
{{ t('appstore', 'This app cannot be installed because the following dependencies are not fulfilled:') }} |
||||
<ul class="missing-dependencies"> |
||||
<li v-for="(dep, index) in app.missingDependencies" :key="index"> |
||||
{{ dep }} |
||||
</li> |
||||
</ul> |
||||
</li> |
||||
</ul> |
||||
|
||||
<div v-if="lastModified && !app.shipped" class="app-details__section"> |
||||
<h4> |
||||
{{ t('appstore', 'Latest updated') }} |
||||
</h4> |
||||
<NcDateTime :timestamp="lastModified" /> |
||||
</div> |
||||
|
||||
<div class="app-details__section"> |
||||
<h4> |
||||
{{ t('appstore', 'Author') }} |
||||
</h4> |
||||
<p class="app-details__authors"> |
||||
{{ appAuthors }} |
||||
</p> |
||||
</div> |
||||
|
||||
<div class="app-details__section"> |
||||
<h4> |
||||
{{ t('appstore', 'Categories') }} |
||||
</h4> |
||||
<p> |
||||
{{ appCategories }} |
||||
</p> |
||||
</div> |
||||
|
||||
<div v-if="externalResources.length > 0" class="app-details__section"> |
||||
<h4>{{ t('appstore', 'Resources') }}</h4> |
||||
<ul class="app-details__documentation" :aria-label="t('appstore', 'Documentation')"> |
||||
<li v-for="resource of externalResources" :key="resource.id"> |
||||
<a |
||||
class="appslink" |
||||
:href="resource.href" |
||||
target="_blank" |
||||
rel="noreferrer noopener"> |
||||
{{ resource.label }} ↗ |
||||
</a> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
|
||||
<div class="app-details__section"> |
||||
<h4>{{ t('appstore', 'Interact') }}</h4> |
||||
<div class="app-details__interact"> |
||||
<NcButton |
||||
:disabled="!app.bugs" |
||||
:href="app.bugs ?? '#'" |
||||
:aria-label="t('appstore', 'Report a bug')" |
||||
:title="t('appstore', 'Report a bug')"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="mdiBugOutline" /> |
||||
</template> |
||||
</NcButton> |
||||
<NcButton |
||||
:disabled="!app.bugs" |
||||
:href="app.bugs ?? '#'" |
||||
:aria-label="t('appstore', 'Request feature')" |
||||
:title="t('appstore', 'Request feature')"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="mdiFeatureSearchOutline" /> |
||||
</template> |
||||
</NcButton> |
||||
<NcButton |
||||
v-if="app.appstoreData?.discussion" |
||||
:href="app.appstoreData.discussion" |
||||
:aria-label="t('appstore', 'Ask questions or discuss')" |
||||
:title="t('appstore', 'Ask questions or discuss')"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="mdiTooltipQuestionOutline" /> |
||||
</template> |
||||
</NcButton> |
||||
<NcButton |
||||
v-if="!app.internal" |
||||
:href="rateAppUrl" |
||||
:aria-label="t('appstore', 'Rate the app')" |
||||
:title="t('appstore', 'Rate')"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="mdiStar" /> |
||||
</template> |
||||
</NcButton> |
||||
</div> |
||||
</div> |
||||
|
||||
<AppDeployOptionsModal |
||||
v-if="app?.app_api" |
||||
:show.sync="showDeployOptionsModal" |
||||
:app="app" /> |
||||
<DaemonSelectionDialog |
||||
v-if="app?.app_api" |
||||
:show.sync="showSelectDaemonModal" |
||||
:app="app" |
||||
:deploy-options="deployOptions" /> |
||||
</div> |
||||
</NcAppSidebarTab> |
||||
</template> |
||||
|
||||
<script> |
||||
import { mdiBugOutline, mdiFeatureSearchOutline, mdiStar, mdiTextBoxOutline, mdiTooltipQuestionOutline, mdiToyBrickPlusOutline } from '@mdi/js' |
||||
import { subscribe, unsubscribe } from '@nextcloud/event-bus' |
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' |
||||
import NcButton from '@nextcloud/vue/components/NcButton' |
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' |
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime' |
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' |
||||
import NcSelect from '@nextcloud/vue/components/NcSelect' |
||||
import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue' |
||||
import AppDeployOptionsModal from './AppDeployOptionsModal.vue' |
||||
import AppManagement from '../../mixins/AppManagement.js' |
||||
import { useAppApiStore } from '../../store/app-api-store.js' |
||||
import { useAppsStore } from '../../store/apps-store.js' |
||||
|
||||
export default { |
||||
name: 'AppDetailsTab', |
||||
|
||||
components: { |
||||
NcAppSidebarTab, |
||||
NcButton, |
||||
NcDateTime, |
||||
NcIconSvgWrapper, |
||||
NcSelect, |
||||
NcCheckboxRadioSwitch, |
||||
AppDeployOptionsModal, |
||||
DaemonSelectionDialog, |
||||
}, |
||||
|
||||
mixins: [AppManagement], |
||||
|
||||
props: { |
||||
app: { |
||||
type: Object, |
||||
required: true, |
||||
}, |
||||
}, |
||||
|
||||
setup() { |
||||
const store = useAppsStore() |
||||
const appApiStore = useAppApiStore() |
||||
|
||||
return { |
||||
store, |
||||
appApiStore, |
||||
|
||||
productName: window.OC.theme.productName, |
||||
|
||||
mdiBugOutline, |
||||
mdiFeatureSearchOutline, |
||||
mdiStar, |
||||
mdiTextBoxOutline, |
||||
mdiTooltipQuestionOutline, |
||||
mdiToyBrickPlusOutline, |
||||
} |
||||
}, |
||||
|
||||
data() { |
||||
return { |
||||
groupCheckedAppsData: false, |
||||
removeData: false, |
||||
showDeployOptionsModal: false, |
||||
showSelectDaemonModal: false, |
||||
deployOptions: null, |
||||
} |
||||
}, |
||||
|
||||
computed: { |
||||
lastModified() { |
||||
return (this.app.appstoreData?.releases ?? []) |
||||
.map(({ lastModified }) => Date.parse(lastModified)) |
||||
.sort() |
||||
.at(0) ?? null |
||||
}, |
||||
|
||||
/** |
||||
* App authors as comma separated string |
||||
*/ |
||||
appAuthors() { |
||||
if (!this.app) { |
||||
return '' |
||||
} |
||||
|
||||
const authorName = (xmlNode) => { |
||||
if (xmlNode['@value']) { |
||||
// Complex node (with email or homepage attribute) |
||||
return xmlNode['@value'] |
||||
} |
||||
// Simple text node |
||||
return xmlNode |
||||
} |
||||
|
||||
const authors = Array.isArray(this.app.author) |
||||
? this.app.author.map(authorName) |
||||
: [authorName(this.app.author)] |
||||
|
||||
return authors |
||||
.sort((a, b) => a.split(' ').at(-1).localeCompare(b.split(' ').at(-1))) |
||||
.join(', ') |
||||
}, |
||||
|
||||
appstoreUrl() { |
||||
return `https://apps.nextcloud.com/apps/${this.app.id}` |
||||
}, |
||||
|
||||
/** |
||||
* Further external resources (e.g. website) |
||||
*/ |
||||
externalResources() { |
||||
const resources = [] |
||||
if (!this.app.internal) { |
||||
resources.push({ |
||||
id: 'appstore', |
||||
href: this.appstoreUrl, |
||||
label: t('appstore', 'View in store'), |
||||
}) |
||||
} |
||||
if (this.app.website) { |
||||
resources.push({ |
||||
id: 'website', |
||||
href: this.app.website, |
||||
label: t('appstore', 'Visit website'), |
||||
}) |
||||
} |
||||
if (this.app.documentation) { |
||||
if (this.app.documentation.user) { |
||||
resources.push({ |
||||
id: 'doc-user', |
||||
href: this.app.documentation.user, |
||||
label: t('appstore', 'Usage documentation'), |
||||
}) |
||||
} |
||||
if (this.app.documentation.admin) { |
||||
resources.push({ |
||||
id: 'doc-admin', |
||||
href: this.app.documentation.admin, |
||||
label: t('appstore', 'Admin documentation'), |
||||
}) |
||||
} |
||||
if (this.app.documentation.developer) { |
||||
resources.push({ |
||||
id: 'doc-developer', |
||||
href: this.app.documentation.developer, |
||||
label: t('appstore', 'Developer documentation'), |
||||
}) |
||||
} |
||||
} |
||||
return resources |
||||
}, |
||||
|
||||
appCategories() { |
||||
return [this.app.category].flat() |
||||
.map((id) => this.store.getCategoryById(id)?.displayName ?? id) |
||||
.join(', ') |
||||
}, |
||||
|
||||
rateAppUrl() { |
||||
return `${this.appstoreUrl}#comments` |
||||
}, |
||||
|
||||
appGroups() { |
||||
return this.app.groups.map((group) => { |
||||
return { id: group, name: group } |
||||
}) |
||||
}, |
||||
|
||||
groups() { |
||||
return this.$store.getters.getGroups |
||||
.filter((group) => group.id !== 'disabled') |
||||
.sort((a, b) => a.name.localeCompare(b.name)) |
||||
}, |
||||
}, |
||||
|
||||
beforeUnmount() { |
||||
this.deployOptions = null |
||||
unsubscribe('showDaemonSelectionModal') |
||||
}, |
||||
|
||||
mounted() { |
||||
if (this.app.groups.length > 0) { |
||||
this.groupCheckedAppsData = true |
||||
} |
||||
subscribe('showDaemonSelectionModal', (deployOptions) => { |
||||
this.showSelectionModal(deployOptions) |
||||
}) |
||||
}, |
||||
|
||||
methods: { |
||||
toggleRemoveData() { |
||||
this.removeData = !this.removeData |
||||
}, |
||||
|
||||
showSelectionModal(deployOptions = null) { |
||||
this.deployOptions = deployOptions |
||||
this.showSelectDaemonModal = true |
||||
}, |
||||
|
||||
async enableButtonAction() { |
||||
if (!this.app?.app_api) { |
||||
this.enable(this.app.id) |
||||
return |
||||
} |
||||
await this.appApiStore.fetchDockerDaemons() |
||||
if (this.appApiStore.dockerDaemons.length === 1 && this.app.needsDownload) { |
||||
this.enable(this.app.id, this.appApiStore.dockerDaemons[0]) |
||||
} else if (this.app.needsDownload) { |
||||
this.showSelectionModal() |
||||
} else { |
||||
this.enable(this.app.id, this.app.daemon) |
||||
} |
||||
}, |
||||
}, |
||||
} |
||||
</script> |
||||
|
||||
<style scoped lang="scss"> |
||||
.app-details { |
||||
padding: 20px; |
||||
|
||||
&__actions { |
||||
// app management |
||||
&-manage { |
||||
// if too many, shrink them and ellipsis |
||||
display: flex; |
||||
align-items: center; |
||||
input { |
||||
flex: 0 1 auto; |
||||
min-width: 0; |
||||
text-overflow: ellipsis; |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
} |
||||
} |
||||
} |
||||
&__authors { |
||||
color: var(--color-text-maxcontrast); |
||||
} |
||||
|
||||
&__section { |
||||
margin-top: 15px; |
||||
|
||||
h4 { |
||||
font-size: 16px; |
||||
font-weight: bold; |
||||
margin-block-end: 5px; |
||||
} |
||||
} |
||||
|
||||
&__interact { |
||||
display: flex; |
||||
flex-direction: row; |
||||
flex-wrap: wrap; |
||||
gap: 12px; |
||||
} |
||||
|
||||
&__documentation { |
||||
a { |
||||
text-decoration: underline; |
||||
} |
||||
li { |
||||
padding-inline-start: 20px; |
||||
|
||||
&::before { |
||||
width: 5px; |
||||
height: 5px; |
||||
border-radius: 100%; |
||||
background-color: var(--color-main-text); |
||||
content: ""; |
||||
float: inline-start; |
||||
margin-inline-start: -13px; |
||||
position: relative; |
||||
top: 10px; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
.force { |
||||
color: var(--color-text-error); |
||||
border-color: var(--color-border-error); |
||||
background: var(--color-main-background); |
||||
} |
||||
|
||||
.force:hover, |
||||
.force:active { |
||||
color: var(--color-main-background); |
||||
border-color: var(--color-border-error) !important; |
||||
background: var(--color-error); |
||||
} |
||||
|
||||
.missing-dependencies { |
||||
list-style: initial; |
||||
list-style-type: initial; |
||||
list-style-position: inside; |
||||
} |
||||
</style> |
||||
@ -1,57 +0,0 @@ |
||||
<!-- |
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors |
||||
- SPDX-License-Identifier: AGPL-3.0-or-later |
||||
--> |
||||
<template> |
||||
<NcAppSidebarTab |
||||
v-if="hasChangelog" |
||||
id="changelog" |
||||
:name="t('appstore', 'Changelog')" |
||||
:order="2"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="mdiClockFast" :size="24" /> |
||||
</template> |
||||
<div v-for="release in app.releases" :key="release.version" class="app-sidebar-tabs__release"> |
||||
<h2>{{ release.version }}</h2> |
||||
<MarkdownPreview |
||||
class="app-sidebar-tabs__release-text" |
||||
:text="createChangelogFromRelease(release)" /> |
||||
</div> |
||||
</NcAppSidebarTab> |
||||
</template> |
||||
|
||||
<script setup lang="ts"> |
||||
import type { IAppstoreApp, IAppstoreAppRelease } from '../../app-types.ts' |
||||
|
||||
import { mdiClockFast } from '@mdi/js' |
||||
import { getLanguage, translate as t } from '@nextcloud/l10n' |
||||
import { computed } from 'vue' |
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' |
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' |
||||
import MarkdownPreview from '../MarkdownPreview.vue' |
||||
|
||||
const props = defineProps<{ app: IAppstoreApp }>() |
||||
|
||||
const hasChangelog = computed(() => Object.values(props.app.releases?.[0]?.translations ?? {}).some(({ changelog }) => !!changelog)) |
||||
|
||||
const createChangelogFromRelease = (release: IAppstoreAppRelease) => release.translations?.[getLanguage()]?.changelog ?? release.translations?.en?.changelog ?? '' |
||||
</script> |
||||
|
||||
<style scoped lang="scss"> |
||||
.app-sidebar-tabs__release { |
||||
h2 { |
||||
border-bottom: 1px solid var(--color-border); |
||||
font-size: 24px; |
||||
} |
||||
|
||||
&-text { |
||||
// Overwrite changelog heading styles |
||||
:deep(h3) { |
||||
font-size: 20px; |
||||
} |
||||
:deep(h4) { |
||||
font-size: 17px; |
||||
} |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,267 @@ |
||||
<!-- |
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors |
||||
- SPDX-License-Identifier: AGPL-3.0-or-later |
||||
--> |
||||
|
||||
<script setup lang="ts"> |
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts' |
||||
|
||||
import { mdiTextBoxOutline } from '@mdi/js' |
||||
import { getCapabilities } from '@nextcloud/capabilities' |
||||
import { t } from '@nextcloud/l10n' |
||||
import { computed, useId } from 'vue' |
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' |
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime' |
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' |
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' |
||||
import AppDaemonBadge from '../AppDaemonBadge.vue' |
||||
import AppLevelBadge from '../AppLevelBadge.vue' |
||||
import AppScore from '../AppScore.vue' |
||||
import { useAppsStore } from '../../store/apps.ts' |
||||
|
||||
const { app } = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>() |
||||
|
||||
const store = useAppsStore() |
||||
|
||||
// @ts-expect-error - missing types |
||||
const productName = getCapabilities().theming.productName as string |
||||
const idLimitedToGroups = useId() |
||||
|
||||
const lastModified = computed(() => app.releases |
||||
?.map((release) => release.lastModified) |
||||
.map((date) => Date.parse(date)) |
||||
.sort() |
||||
.at(-1)) |
||||
|
||||
/** |
||||
* App authors as comma separated string |
||||
*/ |
||||
const appAuthors = computed(() => { |
||||
if (!app) { |
||||
return '' |
||||
} |
||||
|
||||
return [app.author].flat().map(authorName) |
||||
.sort((a, b) => a.split(' ').at(-1)!.localeCompare(b.split(' ').at(-1)!)) |
||||
.join(', ') |
||||
}) |
||||
|
||||
const groupsAppIsLimitedto = computed(() => { |
||||
if (!app.groups) { |
||||
return [] |
||||
} |
||||
|
||||
return app.groups.map((group) => ({ id: group, name: group })) |
||||
}) |
||||
|
||||
const appstoreUrl = computed(() => `https://apps.nextcloud.com/apps/${app.id}`) |
||||
|
||||
/** |
||||
* Further external resources (e.g. website) |
||||
*/ |
||||
const externalResources = computed(() => { |
||||
const resources: { id: string, href: string, label: string }[] = [] |
||||
if (!app.internal) { |
||||
resources.push({ |
||||
id: 'appstore', |
||||
href: appstoreUrl.value, |
||||
label: t('appstore', 'View in store'), |
||||
}) |
||||
} |
||||
if (app.website) { |
||||
resources.push({ |
||||
id: 'website', |
||||
href: app.website, |
||||
label: t('appstore', 'Visit website'), |
||||
}) |
||||
} |
||||
if (app.documentation) { |
||||
if (app.documentation.user) { |
||||
resources.push({ |
||||
id: 'doc-user', |
||||
href: app.documentation.user, |
||||
label: t('appstore', 'Usage documentation'), |
||||
}) |
||||
} |
||||
if (app.documentation.admin) { |
||||
resources.push({ |
||||
id: 'doc-admin', |
||||
href: app.documentation.admin, |
||||
label: t('appstore', 'Admin documentation'), |
||||
}) |
||||
} |
||||
if (app.documentation.developer) { |
||||
resources.push({ |
||||
id: 'doc-developer', |
||||
href: app.documentation.developer, |
||||
label: t('appstore', 'Developer documentation'), |
||||
}) |
||||
} |
||||
} |
||||
return resources |
||||
}) |
||||
|
||||
const appCategories = computed(() => { |
||||
return [app.category].flat() |
||||
.map((id) => store.getCategoryById(id)?.displayName ?? id) |
||||
.join(', ') |
||||
}) |
||||
|
||||
/** |
||||
* Get the author name from the XML node |
||||
* |
||||
* @param xmlNode - The XML node to get the author name from |
||||
*/ |
||||
function authorName(xmlNode): string { |
||||
if (xmlNode['@value']) { |
||||
// Complex node (with email or homepage attribute) |
||||
return xmlNode['@value'] |
||||
} |
||||
// Simple text node |
||||
return xmlNode |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NcAppSidebarTab |
||||
id="details" |
||||
:name="t('appstore', 'Details')" |
||||
:order="1"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="mdiTextBoxOutline" /> |
||||
</template> |
||||
<div class="app-details"> |
||||
<!-- Featured/Supported badges --> |
||||
<div :class="$style.appstoreDetailsTab__badges"> |
||||
<AppLevelBadge :level="app.level" /> |
||||
<AppDaemonBadge v-if="app.app_api && app.daemon" :daemon="app.daemon" /> |
||||
<AppScore v-if="(app.ratingNumOverall ?? 0) > 5" :score="app.ratingOverall!" /> |
||||
</div> |
||||
|
||||
<NcNoteCard v-if="app.missingMinNextcloudVersion || app.missingMaxNextcloudVersion" type="warning"> |
||||
<template v-if="app.missingMinNextcloudVersion"> |
||||
{{ t('appstore', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }} |
||||
</template> |
||||
<template v-if="app.missingMaxNextcloudVersion"> |
||||
{{ t('appstore', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }} |
||||
</template> |
||||
</NcNoteCard> |
||||
|
||||
<NcNoteCard v-if="!app.isCompatible && app.missingDependencies && app.missingDependencies.length" type="error"> |
||||
{{ t('appstore', 'This app cannot be installed because the following dependencies are not fulfilled:') }} |
||||
<ul :aria-label="t('appstore', 'Missing dependencies')" :class="$style.appstoreDetailsTab__missingDependencies"> |
||||
<li v-for="(dep, index) in app.missingDependencies" :key="index"> |
||||
{{ dep }} |
||||
</li> |
||||
</ul> |
||||
</NcNoteCard> |
||||
|
||||
<div v-if="groupsAppIsLimitedto.length" :class="$style.appstoreDetailsTab__section"> |
||||
<h4 :id="idLimitedToGroups"> |
||||
{{ t('appstore', 'Limited to groups') }} |
||||
</h4> |
||||
<ul :aria-labelledby="idLimitedToGroups" :class="$style.appstoreDetailsTab__sectionDetails"> |
||||
<li |
||||
v-for="group of groupsAppIsLimitedto" |
||||
:key="group.id" |
||||
:title="group.id"> |
||||
{{ group.name }} |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
|
||||
<div v-if="lastModified && !app.shipped" :class="$style.appstoreDetailsTab__section"> |
||||
<h4> |
||||
{{ t('appstore', 'Latest updated') }} |
||||
</h4> |
||||
<NcDateTime :class="$style.appstoreDetailsTab__sectionDetails" :timestamp="lastModified" /> |
||||
</div> |
||||
|
||||
<div :class="$style.appstoreDetailsTab__section"> |
||||
<h4> |
||||
{{ t('appstore', 'Author') }} |
||||
</h4> |
||||
<p :class="$style.appstoreDetailsTab__sectionDetails"> |
||||
{{ appAuthors }} |
||||
</p> |
||||
</div> |
||||
|
||||
<div :class="$style.appstoreDetailsTab__section"> |
||||
<h4> |
||||
{{ t('appstore', 'Categories') }} |
||||
</h4> |
||||
<p :class="$style.appstoreDetailsTab__sectionDetails"> |
||||
{{ appCategories }} |
||||
</p> |
||||
</div> |
||||
|
||||
<div v-if="externalResources.length > 0" :class="$style.appstoreDetailsTab__section"> |
||||
<h4>{{ t('appstore', 'Resources') }}</h4> |
||||
<ul |
||||
:class="$style.appstoreDetailsTab__resources" |
||||
:aria-label="t('appstore', 'Documentation resources')"> |
||||
<li |
||||
v-for="resource of externalResources" |
||||
:key="resource.id" |
||||
:class="$style.appstoreDetailsTab__resourcesItem"> |
||||
<a |
||||
:class="$style.appstoreDetailsTab__resourcesLink" |
||||
:href="resource.href" |
||||
target="_blank" |
||||
rel="noreferrer noopener"> |
||||
{{ resource.label }} ↗ |
||||
</a> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
</div> |
||||
</NcAppSidebarTab> |
||||
</template> |
||||
|
||||
<style module> |
||||
.appstoreDetailsTab__badges { |
||||
display: flex; |
||||
flex-direction: row; |
||||
gap: 12px; |
||||
} |
||||
|
||||
.appstoreDetailsTab__section { |
||||
margin-top: 15px; |
||||
|
||||
h4 { |
||||
font-size: 16px; |
||||
font-weight: bold; |
||||
margin-block-end: 5px; |
||||
} |
||||
} |
||||
|
||||
.appstoreDetailsTab__sectionDetails { |
||||
color: var(--color-text-maxcontrast); |
||||
} |
||||
|
||||
.appstoreDetailsTab__missingDependencies { |
||||
list-style: disc; |
||||
padding-block: 0.5lh 0; |
||||
padding-inline: 1em 0; |
||||
} |
||||
|
||||
.appstoreDetailsTab__resourcesLink { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.appstoreDetailsTab__resourcesItem { |
||||
padding-inline-start: 20px; |
||||
|
||||
&::before { |
||||
width: 5px; |
||||
height: 5px; |
||||
border-radius: 100%; |
||||
background-color: var(--color-main-text); |
||||
content: ""; |
||||
float: inline-start; |
||||
margin-inline-start: -13px; |
||||
position: relative; |
||||
top: 10px; |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,72 @@ |
||||
<!-- |
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors |
||||
- SPDX-License-Identifier: AGPL-3.0-or-later |
||||
--> |
||||
|
||||
<script setup lang="ts"> |
||||
import type { IAppstoreApp, IAppstoreAppRelease, IAppstoreExApp } from '../../apps.d.ts' |
||||
|
||||
import { mdiClockFast } from '@mdi/js' |
||||
import { getLanguage, t } from '@nextcloud/l10n' |
||||
import { computed } from 'vue' |
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' |
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' |
||||
import MarkdownPreview from '../MarkdownPreview.vue' |
||||
|
||||
const props = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>() |
||||
|
||||
const releases = computed(() => (props.app.releases ?? []) |
||||
.filter((release) => { |
||||
const values = Object.values(release.translations ?? {}) |
||||
return values.length > 0 && values.some(({ changelog }) => !!changelog) |
||||
})) |
||||
|
||||
/** |
||||
* Create a changelog text from a release |
||||
* |
||||
* @param release - The release to create the changelog from |
||||
*/ |
||||
function createChangelogFromRelease(release: IAppstoreAppRelease) { |
||||
const localizedEntry = release.translations[getLanguage()] |
||||
return localizedEntry?.changelog ?? release.translations.en?.changelog ?? '' |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NcAppSidebarTab |
||||
v-if="releases.length > 0" |
||||
id="changelog" |
||||
:name="t('appstore', 'Changelog')" |
||||
:order="2"> |
||||
<template #icon> |
||||
<NcIconSvgWrapper :path="mdiClockFast" :size="24" /> |
||||
</template> |
||||
<div v-for="release in releases" :key="release.version" :class="$style.appReleasesTab"> |
||||
<h3 :class="$style.appReleasesTab__heading"> |
||||
{{ release.version }} |
||||
</h3> |
||||
<MarkdownPreview |
||||
:class="$style.appReleasesTab__text" |
||||
:minHeadingLevel="3" |
||||
:text="createChangelogFromRelease(release)" /> |
||||
</div> |
||||
</NcAppSidebarTab> |
||||
</template> |
||||
|
||||
<style module> |
||||
.appReleasesTab__heading { |
||||
border-bottom: 1px solid var(--color-border); |
||||
font-size: 20px; |
||||
} |
||||
|
||||
.appReleasesTab__text { |
||||
/* Overwrite changelog heading styles */ |
||||
h4 { |
||||
font-size: 19px; |
||||
} |
||||
|
||||
h5 { |
||||
font-size: 17px; |
||||
} |
||||
} |
||||
</style> |
||||
@ -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> |
||||
@ -0,0 +1,63 @@ |
||||
/** |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
*/ |
||||
|
||||
import type { OCSResponse } from '@nextcloud/typings/ocs' |
||||
import type { NcSelectUsersModel } from '@nextcloud/vue/components/NcSelectUsers' |
||||
|
||||
import axios from '@nextcloud/axios' |
||||
import { generateOcsUrl } from '@nextcloud/router' |
||||
import { defineStore } from 'pinia' |
||||
import { computed, ref } from 'vue' |
||||
import logger from '../utils/logger.ts' |
||||
|
||||
export const useGroupsStore = defineStore('groups', () => { |
||||
const groups = ref(new Map<string, NcSelectUsersModel>()) |
||||
|
||||
/** |
||||
* Search the API for groups matching the query |
||||
* |
||||
* @param query - Query to search |
||||
*/ |
||||
async function searchGroups(query: string) { |
||||
const url = generateOcsUrl('/cloud/groups/details') |
||||
try { |
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data } = await axios.get<OCSResponse<{ groups: any }>>(url, { |
||||
params: { |
||||
search: query.trim(), |
||||
limit: 10, |
||||
}, |
||||
}) |
||||
for (const group of data.ocs.data.groups) { |
||||
if (groups.value.has(group.id)) { |
||||
continue |
||||
} |
||||
|
||||
groups.value.set(group.id, { |
||||
id: group.id, |
||||
displayName: group.displayname, |
||||
isNoUser: true, |
||||
}) |
||||
} |
||||
} catch (error) { |
||||
logger.error('Failed to search groups', { error }) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get a group by its id |
||||
* |
||||
* @param groupId - The id of the group to retrieve |
||||
*/ |
||||
function getGroupById(groupId: string) { |
||||
return groups.value.get(groupId) |
||||
} |
||||
|
||||
return { |
||||
groups: computed(() => Array.from(groups.value.values())), |
||||
searchGroups, |
||||
getGroupById, |
||||
} |
||||
}) |
||||
@ -1,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> |
||||
@ -0,0 +1,137 @@ |
||||
<!-- |
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors |
||||
- SPDX-License-Identifier: AGPL-3.0-or-later |
||||
--> |
||||
|
||||
<script setup lang="ts"> |
||||
import { t } from '@nextcloud/l10n' |
||||
import { computed, onMounted, ref, watch } from 'vue' |
||||
import { useRoute, useRouter } from 'vue-router' |
||||
import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar' |
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' |
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' |
||||
import AppActions from '../components/AppActions.vue' |
||||
import AppDeployDaemonTab from '../components/AppstoreSidebar/AppDeployDaemonTab.vue' |
||||
import AppDescriptionTab from '../components/AppstoreSidebar/AppDescriptionTab.vue' |
||||
import AppDetailsTab from '../components/AppstoreSidebar/AppDetailsTab.vue' |
||||
import AppReleasesTab from '../components/AppstoreSidebar/AppReleasesTab.vue' |
||||
import { useActions } from '../composables/useActions.ts' |
||||
import { useAppIcon } from '../composables/useAppIcon.ts' |
||||
import { useAppsStore } from '../store/apps.ts' |
||||
|
||||
const route = useRoute() |
||||
const router = useRouter() |
||||
const store = useAppsStore() |
||||
|
||||
const appId = computed<string>(() => [route.params.id].flat()[0]!) |
||||
const app = computed(() => store.getAppById(appId.value) ?? null) |
||||
const { appIcon } = useAppIcon(app) |
||||
|
||||
/** |
||||
* The second text line shown on the sidebar |
||||
*/ |
||||
const licenseText = computed(() => { |
||||
if (!app.value) { |
||||
return '' |
||||
} |
||||
|
||||
if (app.value.license) { |
||||
return t('appstore', 'Version {version}, {license}-licensed', { version: app.value.version, license: String(app.value.license).toUpperCase() }) |
||||
} |
||||
return t('appstore', 'Version {version}', { version: app.value.version }) |
||||
}) |
||||
|
||||
const activeTab = ref('details') |
||||
watch([app], () => { |
||||
activeTab.value = 'details' |
||||
}) |
||||
|
||||
/** |
||||
* Hide the details sidebar by pushing a new route |
||||
*/ |
||||
function hideAppDetails() { |
||||
router.replace({ |
||||
name: route.name!, |
||||
params: { |
||||
...route.params, |
||||
id: undefined, |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
/** |
||||
* Whether the app screenshot is loaded |
||||
*/ |
||||
const screenshotLoaded = ref(false) |
||||
const hasScreenshot = computed(() => app.value?.screenshot && screenshotLoaded.value) |
||||
/** |
||||
* Preload the app screenshot |
||||
*/ |
||||
function loadScreenshot() { |
||||
if (app.value?.releases && app.value?.screenshot) { |
||||
const image = new Image() |
||||
image.onload = () => { |
||||
screenshotLoaded.value = true |
||||
} |
||||
image.src = app.value.screenshot |
||||
} |
||||
} |
||||
// Watch app and set screenshot loaded when |
||||
watch([app], loadScreenshot) |
||||
onMounted(loadScreenshot) |
||||
|
||||
const actions = useActions(() => app.value) |
||||
</script> |
||||
|
||||
<template> |
||||
<!-- Selected app details --> |
||||
<NcAppSidebar |
||||
v-model:active="activeTab" |
||||
:class="[$style.appstoreSidebar, { [$style.appstoreSidebar_withScreenshot]: hasScreenshot }]" |
||||
:background="hasScreenshot ? app!.screenshot : undefined" |
||||
:compact="!hasScreenshot" |
||||
:name="app?.name ?? appId" |
||||
:title="app?.name ?? appId" |
||||
:subname="licenseText" |
||||
:subtitle="licenseText" |
||||
@close="hideAppDetails"> |
||||
<!-- Fallback icon in case no app icon is available --> |
||||
<template v-if="!hasScreenshot" #header> |
||||
<NcIconSvgWrapper |
||||
:class="$style.appstoreSidebar__fallbackIcon" |
||||
:svg="appIcon ?? ''" |
||||
:size="64" /> |
||||
</template> |
||||
|
||||
<template v-if="app" #description> |
||||
<AppActions |
||||
:app |
||||
:actions |
||||
iconOnly |
||||
:maxInlineActions="6" /> |
||||
</template> |
||||
|
||||
<!-- Tab content --> |
||||
<NcEmptyContent v-if="!app" name="No such app" /> |
||||
<template v-else> |
||||
<AppDescriptionTab :app /> |
||||
<AppReleasesTab :app /> |
||||
<AppDetailsTab :app /> |
||||
<AppDeployDaemonTab v-if="app.app_api" :app /> |
||||
</template> |
||||
</NcAppSidebar> |
||||
</template> |
||||
|
||||
<style module> |
||||
/* If a screenshot is available it should cover the whole figure */ |
||||
.appstoreSidebar_withScreenshot { |
||||
:global(.app-sidebar-header__figure) { |
||||
background-size: cover; |
||||
} |
||||
} |
||||
|
||||
.appstoreSidebar__fallbackIcon { |
||||
width: 100%; |
||||
height: 100%; |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue