Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/settings/lib/Controller/AppSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public function viewApps(): TemplateResponse {
$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
$this->initialState->provideInitialState('isAllInOne', filter_var(getenv('THIS_IS_AIO'), FILTER_VALIDATE_BOOL));

if ($this->appManager->isEnabledForAnyone('app_api')) {
try {
Expand Down
49 changes: 48 additions & 1 deletion apps/settings/src/components/AppList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@

<template>
<div id="app-content-inner">
<OfficeSuiteSwitcher
v-if="category === 'office'"
:installed-apps="allApps"
@suite-selected="onSuiteSelected" />

<div
id="apps-list"
class="apps-list"
Expand Down Expand Up @@ -150,6 +155,8 @@ 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 logger from '../logger.ts'
import AppManagement from '../mixins/AppManagement.js'
import { useAppApiStore } from '../store/app-api-store.ts'
Expand All @@ -160,6 +167,7 @@ export default {
components: {
AppItem,
NcButton,
OfficeSuiteSwitcher,
},
mixins: [AppManagement],
Expand Down Expand Up @@ -207,6 +215,11 @@ export default {
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 : []
Expand Down Expand Up @@ -308,7 +321,7 @@ export default {
},
},
beforeDestroy() {
beforeUnmount() {
unsubscribe('nextcloud:unified-search.search', this.setSearch)
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
},
Expand All @@ -327,6 +340,40 @@ export default {
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)
Expand Down
220 changes: 220 additions & 0 deletions apps/settings/src/components/AppList/OfficeSuiteSwitcher.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<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 officeSuites"
: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 }}
</h3>
<IconCheck 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>
</template>
</div>
</template>

<script>
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import { OFFICE_SUITES } from '../../constants/OfficeSuites.js'

export default {
name: 'OfficeSuiteSwitcher',

components: {
IconCheck,
IconArrowRight,
},

props: {
installedApps: {
type: Array,
default: () => [],
},
},

emits: ['suite-selected'],

data() {
return {
isAllInOne: loadState('settings', 'isAllInOne', false),
selectedSuite: this.getInitialSuite(),
officeSuites: OFFICE_SUITES,
}
},

methods: {
t,
getInitialSuite() {
for (const suite of OFFICE_SUITES) {
const app = this.installedApps.find((a) => a.id === suite.appId)
if (app && app.active) {
return suite.id
}
}

return null
},

selectSuite(suiteId) {
if (this.selectedSuite === suiteId) {
this.selectedSuite = null
this.$emit('suite-selected', null)
} else {
this.selectedSuite = suiteId
this.$emit('suite-selected', suiteId)
}
},
},
}
</script>

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

&: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);
}
}

@media (max-width: 768px) {
.office-suite-cards {
flex-direction: column;
}
}
</style>
56 changes: 56 additions & 0 deletions apps/settings/src/constants/OfficeSuites.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

export const OFFICE_SUITES = [
{
id: 'nextcloud-office',
appId: 'richdocuments',
name: 'Nextcloud Office',
features: [
'Best Nextcloud integration',
'Open source',
'Good performance',
'Best security: documents never leave your server',
'Best ODF compatibility',
'Best support for legacy files',
],
learnMoreUrl: 'https://nextcloud.com/collaboraonline/',
isPrimary: true,
},
{
id: 'onlyoffice',
appId: 'onlyoffice',
name: 'Onlyoffice',
features: [
'Good Nextcloud integration',
'Open core',
'Best performance',
'Limited ODF compatibility',
'Best Microsoft compatibility',
],
learnMoreUrl: 'https://nextcloud.com/onlyoffice/',
isPrimary: false,
},
]

/**
* Get office suite configuration by ID
*
* @param {string} id - The suite ID
* @return {object|undefined} The suite configuration or undefined if not found
*/
export function getOfficeSuiteById(id) {
return OFFICE_SUITES.find((suite) => suite.id === id)
}

/**
* Get office suite configuration by app ID
*
* @param {string} appId - The app ID (richdocuments, onlyoffice, etc.)
* @return {object|undefined} The suite configuration or undefined if not found
*/
export function getOfficeSuiteByAppId(appId) {
return OFFICE_SUITES.find((suite) => suite.appId === appId)
}
1 change: 0 additions & 1 deletion apps/settings/src/views/AppStoreNavigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@
</template>
</NcAppNavigationItem>
</template>

</template>
</NcAppNavigation>
</template>
Expand Down
2 changes: 1 addition & 1 deletion apps/settings/src/views/AppStoreSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

<!-- Tab content -->
<AppDescriptionTab :app="app" />
<AppDetailsTab :app="app" :key="app.id" />
<AppDetailsTab :key="app.id" :app="app" />
<AppReleasesTab :app="app" />
<AppDeployDaemonTab :app="app" />
</NcAppSidebar>
Expand Down