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 @@ -94,6 +94,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));

$groups = array_map(static fn (IGroup $group): array => [
'id' => $group->getGID(),
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
265 changes: 265 additions & 0 deletions apps/settings/src/components/AppList/OfficeSuiteSwitcher.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
<!--
- 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 }}
<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>

<script>
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import IconCheckCircle from 'vue-material-design-icons/CheckCircle.vue'
import { OFFICE_SUITES } from '../../constants/OfficeSuites.js'
export default {
name: 'OfficeSuiteSwitcher',
components: {
IconCheckCircle,
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) {
// already selected — keep selection; use the disable button to clear
return
}
this.selectedSuite = suiteId
this.$emit('suite-selected', suiteId)
},
disableSuites() {
if (this.selectedSuite === null) {
return
}
this.selectedSuite = null
this.$emit('suite-selected', null)
},
},
}
</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;
& * {
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>
Loading
Loading