diff --git a/README.md b/README.md
index ab09eb7ccc..496bf104bc 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,16 @@ Nextcloud Office supports dozens of document formats including DOC, DOCX, PPT, P
Nextcloud Office is based on the Collabora Online Development Edition (CODE) and is available free and under heavy development, adding features and improvements all the time! Enterprise users have access to the more stable, scalable Collabora Online Enterprise based version through a Nextcloud support subscription.
+### Office Overview
+
+The app registers an **Office** entry in the Nextcloud navigation bar. The overview page gives you a single place to:
+
+- Browse recent documents, spreadsheets, presentations, and diagrams — with filter chips for *All*, *Mine* (owned by you), and *Shared with me*.
+- Search within the active category.
+- Create a new file from a blank document or from one of your personal templates.
+- Switch between grid and list view (preference is persisted per user).
+- Open any listed file directly in the editor with a single click.
+
## Installation
Nextcloud Office is built on Collabora Online which requires a dedicated service running next to the Nextcloud webserver stack. There are several ways to run the coolwsd service. For full details, see the related section in the admin manual https://docs.nextcloud.com/server/latest/admin_manual/office/index.html
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 8024e57703..826a042ea2 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -55,4 +55,12 @@ You can also edit your documents off-line with the Collabora Office app from the
OCA\Richdocuments\Settings\Personal
OCA\Richdocuments\Settings\Section
+
+
+ Office
+ richdocuments.overview.index
+ app.svg
+ 10
+
+
diff --git a/appinfo/routes.php b/appinfo/routes.php
index e17267f6ce..a51d67e3b2 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -24,6 +24,9 @@
// external api access
['name' => 'document#extAppGetData', 'url' => '/ajax/extapp/data/{fileId}', 'verb' => 'POST'],
+ // Office overview page
+ ['name' => 'overview#index', 'url' => '/overview', 'verb' => 'GET'],
+
// Settings
['name' => 'settings#setPersonalSettings', 'url' => 'ajax/personal.php', 'verb' => 'POST'],
['name' => 'settings#setSettings', 'url' => 'ajax/admin.php', 'verb' => 'POST'],
@@ -48,6 +51,7 @@
],
],
['name' => 'settings#generateIframeToken', 'url' => 'settings/generateToken/{type}', 'verb' => 'GET'],
+ ['name' => 'settings#setOverviewGridView', 'url' => 'settings/overview/grid_view', 'verb' => 'PUT'],
// Direct Editing: Webview
['name' => 'directView#show', 'url' => '/direct/{token}', 'verb' => 'GET'],
diff --git a/cypress/e2e/overview.spec.js b/cypress/e2e/overview.spec.js
new file mode 100644
index 0000000000..9133f1bb3f
--- /dev/null
+++ b/cypress/e2e/overview.spec.js
@@ -0,0 +1,254 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+const CATEGORY_FILES = [
+ {
+ category: 'Documents',
+ emptyMessage: 'No Documents found',
+ fixture: 'document.odt',
+ mimeType: 'application/vnd.oasis.opendocument.text',
+ },
+ {
+ category: 'Presentations',
+ emptyMessage: 'No Presentations found',
+ fixture: 'presentation.odp',
+ mimeType: 'application/vnd.oasis.opendocument.presentation',
+ },
+ {
+ category: 'Spreadsheets',
+ emptyMessage: 'No Spreadsheets found',
+ fixture: 'spreadsheet.ods',
+ mimeType: 'application/vnd.oasis.opendocument.spreadsheet',
+ },
+ {
+ category: 'Diagrams',
+ emptyMessage: 'No Diagrams found',
+ fixture: 'drawing.odg',
+ mimeType: 'application/vnd.oasis.opendocument.graphics',
+ },
+]
+
+describe('Office overview page', function() {
+ describe('without files', function() {
+ let randUser
+
+ before(function() {
+ cy.createRandomUser().then(user => {
+ randUser = user
+ })
+ })
+
+ beforeEach(function() {
+ cy.login(randUser)
+ cy.visit('/apps/richdocuments/overview')
+ })
+
+ it('Shows the navigation sidebar with appropriate entries', function() {
+ CATEGORY_FILES.forEach(({ category }) => {
+ cy.contains('.app-navigation-entry', category).should('exist')
+ })
+ })
+
+ it('Highlights the active navigation item and shows empty state on click', function() {
+ CATEGORY_FILES.forEach(({ category, emptyMessage }) => {
+ cy.contains('.app-navigation-entry', category).click()
+ cy.contains('.app-navigation-entry', category)
+ .should('have.class', 'active')
+ cy.get('.empty-content')
+ .should('be.visible')
+ .and('contain', emptyMessage)
+ })
+ })
+ })
+
+ describe('with files', function() {
+ let randUser
+
+ before(function() {
+ cy.nextcloudTestingAppConfigSet('richdocuments', 'doc_format', '')
+ cy.createRandomUser().then(user => {
+ randUser = user
+ cy.login(user)
+
+ CATEGORY_FILES.forEach(({ fixture, mimeType }) => {
+ cy.uploadFile(user, fixture, mimeType, `/${fixture}`)
+ })
+
+ cy.createFolder(user, 'subfolder').then(() => {
+ CATEGORY_FILES.forEach(({ fixture, mimeType }) => {
+ cy.uploadFile(user, fixture, mimeType, `/subfolder/${fixture}`)
+ })
+ })
+ })
+ })
+
+ beforeEach(function() {
+ cy.login(randUser)
+ cy.visit('/apps/richdocuments/overview', {
+ onBeforeLoad(win) {
+ cy.spy(win, 'postMessage').as('postMessage')
+ },
+ })
+ })
+
+ CATEGORY_FILES.forEach(({ category, fixture }) => {
+ it(`Shows ${category} file cards in the correct category`, function() {
+ cy.contains('.app-navigation-entry', category).click()
+
+ cy.contains('.file-card__name', fixture)
+ .scrollIntoView()
+ .should('be.visible')
+
+ cy.get('.file-card__preview img')
+ .should('exist')
+
+ cy.get('.app-navigation-search input[type="search"]')
+ .should('have.attr', 'aria-label', `Search ${category}`)
+ })
+
+ it(`Opens the viewer when clicking a ${category} file card`, function() {
+ cy.contains('.app-navigation-entry', category).click()
+ cy.contains('.file-card', fixture).click()
+
+ cy.waitForViewer()
+ cy.waitForCollabora()
+
+ cy.closeDocument()
+ })
+ })
+
+ it('Shows file cards for files in subdirectories', function() {
+ CATEGORY_FILES.forEach(({ category }) => {
+ cy.contains('.app-navigation-entry', category).click()
+ cy.get('.file-card').should('have.length.at.least', 2)
+ })
+ })
+
+ it('Filters file cards by search query', function() {
+ const { category, fixture } = CATEGORY_FILES[0]
+ const stem = fixture.split('.')[0]
+
+ cy.contains('.app-navigation-entry', category).click()
+
+ cy.get('.app-navigation-search input[type="search"]').type(stem)
+ cy.contains('.file-card__name', fixture).should('be.visible')
+ })
+
+ it('Shows empty state when search matches nothing', function() {
+ const { category } = CATEGORY_FILES[0]
+
+ cy.contains('.app-navigation-entry', category).click()
+
+ cy.get('.app-navigation-search input[type="search"]').type('xyz123noresults')
+ cy.get('.empty-content').should('be.visible')
+ })
+
+ it('Resets search when switching categories', function() {
+ const [first, second] = CATEGORY_FILES
+
+ cy.contains('.app-navigation-entry', first.category).click()
+ cy.get('.app-navigation-search input[type="search"]').type('xyz123noresults')
+
+ cy.contains('.app-navigation-entry', second.category).click()
+
+ cy.get('.app-navigation-search input[type="search"]').should('have.value', '')
+ cy.contains('.file-card__name', second.fixture).should('be.visible')
+ })
+ })
+
+ describe('create from template', function() {
+ let randUser
+
+ before(function() {
+ cy.createRandomUser().then(user => {
+ randUser = user
+ })
+ })
+
+ beforeEach(function() {
+ cy.login(randUser)
+ cy.visit('/apps/richdocuments/overview')
+ cy.contains('.app-navigation-entry', 'Documents').click()
+ })
+
+ it('Opens the create dialog with pre-filled filename when clicking Blank', function() {
+ cy.contains('.template-card__name', 'Blank')
+ .closest('.template-card')
+ .click()
+
+ cy.get('[role="dialog"]').should('be.visible')
+ cy.get('[role="dialog"] input[type="text"]').invoke('val').should('match', /\.\w+$/)
+ })
+
+ it('Creates a blank file and navigates to it', function() {
+ cy.intercept('POST', /templates\/create/).as('createFile')
+
+ cy.contains('.template-card__name', 'Blank')
+ .closest('.template-card')
+ .click()
+
+ cy.get('[role="dialog"]').within(() => {
+ cy.contains('button', 'Create').click()
+ })
+
+ cy.wait('@createFile').then(({ request, response }) => {
+ expect(request.body).to.have.property('templatePath', '')
+ expect(response.statusCode).to.equal(200)
+ })
+
+ // After successful creation the page navigates away from the overview
+ cy.location('pathname').should('not.include', 'overview')
+ })
+
+ it('Shows an error message when creation fails', function() {
+ cy.intercept('POST', /templates\/create/, {
+ statusCode: 403,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ocs: {
+ meta: { status: 'failure', statuscode: 403, message: 'File already exists' },
+ data: {},
+ },
+ }),
+ }).as('createFail')
+
+ cy.contains('.template-card__name', 'Blank')
+ .closest('.template-card')
+ .click()
+
+ cy.get('[role="dialog"]').within(() => {
+ cy.contains('button', 'Create').click()
+ })
+
+ cy.wait('@createFail')
+
+ // Dialog stays open and shows the server error message
+ cy.get('[role="dialog"]', { timeout: 8000 }).should('contain.text', 'exists')
+ })
+
+ it('Uses templateId and templateType from the template when clicking a non-blank template', function() {
+ cy.get('.template-section__list .template-card').then($cards => {
+ const nonBlank = $cards.filter((_, el) => !el.querySelector('.template-card__name')?.textContent.includes('Blank'))
+ if (nonBlank.length === 0) {
+ this.skip()
+ return
+ }
+
+ cy.intercept('POST', /templates\/create/).as('createFromTemplate')
+
+ cy.wrap(nonBlank.first()).click()
+
+ cy.get('[role="dialog"]').within(() => {
+ cy.contains('button', 'Create').click()
+ })
+
+ cy.wait('@createFromTemplate').then(({ request }) => {
+ expect(request.body.templatePath).to.not.equal('')
+ expect(request.body.templateType).to.not.equal('user_system')
+ })
+ })
+ })
+ })
+})
diff --git a/cypress/e2e/templates.spec.js b/cypress/e2e/templates.spec.js
index e8da9e890f..e5187bf2a6 100644
--- a/cypress/e2e/templates.spec.js
+++ b/cypress/e2e/templates.spec.js
@@ -52,7 +52,7 @@ describe('Global templates', function() {
.scrollIntoView()
cy.intercept('DELETE', '**/richdocuments/template/*').as('templateDeleteRequest')
- cy.get('.template-btn[data-cy-template-btn-name="systemtemplate"]').click()
+ cy.get('.file-card[data-cy-template-btn-name="systemtemplate"]').click()
cy.wait('@templateDeleteRequest').then(({ response }) => {
expect(response.statusCode).to.equal(204)
diff --git a/lib/Controller/OverviewController.php b/lib/Controller/OverviewController.php
new file mode 100644
index 0000000000..d900797cfb
--- /dev/null
+++ b/lib/Controller/OverviewController.php
@@ -0,0 +1,61 @@
+initialState->provideInitialState('previewEnabled', $this->preview->isMimeSupported('application/vnd.oasis.opendocument.text'));
+ $this->initialState->provideInitialState('overview_config', [
+ 'overview_grid_view' => $this->userId !== null
+ && $this->config->getUserValue($this->userId, 'richdocuments', 'overview_grid_view', '1') === '1',
+ ]);
+
+ // Viewer is pre-installed in production but may not be available in other environments
+ if (class_exists(LoadViewer::class)) {
+ $this->eventDispatcher->dispatchTyped(new LoadViewer());
+ }
+
+ return new TemplateResponse('richdocuments', 'overview', [
+ 'id-app-content' => '#app-content-vue',
+ 'id-app-navigation' => '#app-navigation-vue',
+ ]);
+ }
+}
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index d633f192ae..e822d33c54 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -320,6 +320,15 @@ public function setPersonalSettings($templateFolder,
return new JSONResponse($response);
}
+ #[NoAdminRequired]
+ public function setOverviewGridView(bool $value): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
+ }
+ $this->config->setUserValue($this->userId, 'richdocuments', 'overview_grid_view', $value ? '1' : '0');
+ return new JSONResponse(['message' => 'ok']);
+ }
+
/**
* @NoAdminRequired
* @PublicPage
diff --git a/lib/Listener/LoadViewerListener.php b/lib/Listener/LoadViewerListener.php
index b277d3d018..1f819acfbb 100644
--- a/lib/Listener/LoadViewerListener.php
+++ b/lib/Listener/LoadViewerListener.php
@@ -11,6 +11,7 @@
namespace OCA\Richdocuments\Listener;
+use OCA\Richdocuments\AppConfig;
use OCA\Richdocuments\AppInfo\Application;
use OCA\Richdocuments\PermissionManager;
use OCA\Richdocuments\Service\InitialStateService;
@@ -27,6 +28,7 @@ public function __construct(
private PermissionManager $permissionManager,
private InitialStateService $initialStateService,
private IEventDispatcher $eventDispatcher,
+ private AppConfig $appConfig,
private ?string $userId,
) {
}
@@ -36,7 +38,9 @@ public function handle(Event $event): void {
if (!$event instanceof LoadViewer) {
return;
}
- if ($this->permissionManager->isEnabledForUser() && $this->userId !== null) {
+ if ($this->permissionManager->isEnabledForUser()
+ && $this->userId !== null
+ && $this->appConfig->getCollaboraUrlInternal() !== '') {
$this->initialStateService->provideCapabilities();
Util::addInitScript(Application::APPNAME, Application::APPNAME . '-init-viewer');
Util::addScript(Application::APPNAME, Application::APPNAME . '-viewer', 'viewer');
diff --git a/lib/Template/CollaboraTemplateProvider.php b/lib/Template/CollaboraTemplateProvider.php
index b3b8f54211..c4788f1641 100644
--- a/lib/Template/CollaboraTemplateProvider.php
+++ b/lib/Template/CollaboraTemplateProvider.php
@@ -49,6 +49,7 @@ public function getCustomTemplates(string $mimetype): array {
return array_map(function (File $file) {
$template = new Template(CollaboraTemplateProvider::class, (string)$file->getId(), $file);
$template->setCustomPreviewUrl($this->urlGenerator->linkToRouteAbsolute('richdocuments.templates.getPreview', ['fileId' => $file->getId(), 'a' => true]));
+ $template->setHasPreview(true);
return $template;
}, $collaboraTemplates);
}
diff --git a/lib/TemplateManager.php b/lib/TemplateManager.php
index e37d6e5ab8..19ee5def44 100644
--- a/lib/TemplateManager.php
+++ b/lib/TemplateManager.php
@@ -32,18 +32,21 @@ class TemplateManager {
'application/vnd.oasis.opendocument.text-template',
'application/vnd.oasis.opendocument.text',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword'
];
public const MIMES_SHEETS = [
'application/vnd.oasis.opendocument.spreadsheet-template',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel'
];
public const MIMES_PRESENTATIONS = [
'application/vnd.oasis.opendocument.presentation-template',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.template',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-powerpoint'
];
public const MIMES_DRAWINGS = [
diff --git a/src/components/AdminSettings/GlobalTemplates.vue b/src/components/AdminSettings/GlobalTemplates.vue
index 5b1a82c7b3..2dbbc389c2 100644
--- a/src/components/AdminSettings/GlobalTemplates.vue
+++ b/src/components/AdminSettings/GlobalTemplates.vue
@@ -14,37 +14,37 @@
@change="selectFile">