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">
- -
-
- -
- {{ t('richdocuments', 'New') }} -
-
- -
- -
-
-
- -
+ + + + + + + + +
+ + diff --git a/src/components/TemplateSection.vue b/src/components/TemplateSection.vue new file mode 100644 index 0000000000..7708eda6d2 --- /dev/null +++ b/src/components/TemplateSection.vue @@ -0,0 +1,345 @@ + + + + + + diff --git a/src/overview.js b/src/overview.js new file mode 100644 index 0000000000..2eca428705 --- /dev/null +++ b/src/overview.js @@ -0,0 +1,15 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import './init-shared.js' +import '../css/filetypes.scss' +import Vue from 'vue' +import OfficeOverview from './views/OfficeOverview.vue' + +Vue.prototype.t = t +Vue.prototype.n = n + +new Vue({ + render: h => h(OfficeOverview), +}).$mount('#content') diff --git a/src/services/config.js b/src/services/config.js new file mode 100644 index 0000000000..094f090594 --- /dev/null +++ b/src/services/config.js @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +/** + * Persist the user's grid/list view preference for the Office Overview. + * + * @param {boolean} value True for grid view, false for list view + * @return {Promise} + */ +export async function setOverviewGridView(value) { + await axios.put(generateUrl('/apps/richdocuments/settings/overview/grid_view'), { value }) +} diff --git a/src/services/officeFiles.js b/src/services/officeFiles.js new file mode 100644 index 0000000000..24e7376b06 --- /dev/null +++ b/src/services/officeFiles.js @@ -0,0 +1,94 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getClient, getDavNameSpaces, getDavProperties, getRootPath, resultToNode } from '@nextcloud/files/dav' + +/** + * Build a DAV SEARCH request body that matches files of any of the given MIME types + * across all subdirectories (depth: infinity). + * + * @param {string[]} mimes List of MIME type strings to search for + * @return {string} XML string for the SEARCH request body + */ +function buildOfficeMimeSearch(mimes) { + const escapeXml = s => s.replace(/&/g, '&').replace(//g, '>') + const conditions = mimes + .map(mime => `\t\t\t\t${escapeXml(mime)}`) + .join('\n') + + return ` + + + + + ${getDavProperties()} + + + + + ${getRootPath()}/ + infinity + + + + +${conditions} + + + +` +} + +/** @type {import('@nextcloud/files').Node[]|null} */ +let cachedNodes = null + +/** + * Fetch all office files matching the given MIME types and cache the result. + * Subsequent calls with the same set of MIMEs return the cached array. + * Pass an empty array to invalidate and re-fetch. + * + * TODO: This DAV SEARCH is unpaginated (depth: infinity). For users with very large + * file collections this can be slow and memory-intensive. A v2 should add LIMIT/OFFSET + * or switch to the Files API search endpoint once it supports MIME filtering. + * + * @param {string[]} mimes MIME types to search for, derived from template creators + * @return {Promise} + */ +export async function getAllOfficeFiles(mimes) { + if (cachedNodes) { + return cachedNodes + } + + const client = getClient() + + const response = await client.search('/', { + details: true, + data: buildOfficeMimeSearch(mimes), + }) + + cachedNodes = response.data.results + .map(item => resultToNode(item)) + .filter(node => node.type === 'file') + + return cachedNodes +} + +/** + * Discard the cached file list so the next getAllOfficeFiles() call re-fetches. + */ +export function invalidateOfficeFilesCache() { + cachedNodes = null +} + +/** + * Filter a list of file nodes to those whose MIME type is in the given set. + * + * @param {import('@nextcloud/files').Node[]} files + * @param {string[]} mimes MIME types for the active category + * @return {import('@nextcloud/files').Node[]} + */ +export function filterByMimes(files, mimes) { + return files.filter(file => mimes.includes(file.mime)) +} diff --git a/src/services/templates.js b/src/services/templates.js new file mode 100644 index 0000000000..f11cb68a1d --- /dev/null +++ b/src/services/templates.js @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// Mirrors apps/files/src/services/Templates.js — uses NC core Files API, not richdocuments OCS. + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Fetch all template creators registered with the NC Files API. + * Returns an array of TemplateFileCreator objects, each with: + * app, label, extension, mimetypes[], templates[] + * + * @return {Promise} + */ +export async function getTemplates() { + const response = await axios.get(generateOcsUrl('apps/files/api/v1/templates')) + return response.data.ocs.data +} + +/** + * Create a new file from a template via the NC Files API. + * + * @param {string} filePath Destination path for the new file + * @param {string} templatePath Source template path + * @param {string} templateType Template type e.g. 'user' + * @return {Promise} + */ +export async function createFromTemplate(filePath, templatePath, templateType) { + const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/create'), { + filePath, + templatePath, + templateType, + }) + return response.data.ocs.data +} diff --git a/src/views/OfficeOverview.vue b/src/views/OfficeOverview.vue new file mode 100644 index 0000000000..db61cf07d1 --- /dev/null +++ b/src/views/OfficeOverview.vue @@ -0,0 +1,537 @@ + + + + + + diff --git a/templates/overview.php b/templates/overview.php new file mode 100644 index 0000000000..0024bd549b --- /dev/null +++ b/templates/overview.php @@ -0,0 +1,8 @@ + +
diff --git a/tests/lib/Controller/OverviewControllerTest.php b/tests/lib/Controller/OverviewControllerTest.php new file mode 100644 index 0000000000..3f6c33fc1c --- /dev/null +++ b/tests/lib/Controller/OverviewControllerTest.php @@ -0,0 +1,88 @@ +eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->initialState = $this->createMock(IInitialState::class); + $this->preview = $this->createMock(IPreview::class); + $this->config = $this->createMock(IConfig::class); + $this->config->method('getUserValue')->willReturn('0'); + + $this->controller = new OverviewController( + 'richdocuments', + $this->createMock(IRequest::class), + $this->eventDispatcher, + $this->initialState, + $this->preview, + $this->config, + 'test-user', + ); + } + + public function testIndexReturnsTemplateResponse(): void { + $response = $this->controller->index(); + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('richdocuments', $response->getApp()); + $this->assertSame('overview', $response->getTemplateName()); + $this->assertSame('#app-content-vue', $response->getParams()['id-app-content']); + $this->assertSame('#app-navigation-vue', $response->getParams()['id-app-navigation']); + } + + public function testIndexSetsPreviewEnabledTrue(): void { + $this->preview->expects($this->once()) + ->method('isMimeSupported') + ->with('application/vnd.oasis.opendocument.text') + ->willReturn(true); + + $this->initialState->expects($this->exactly(2)) + ->method('provideInitialState') + ->withConsecutive( + ['previewEnabled', true], + ['overview_config', ['overview_grid_view' => false]] + ); + + $this->controller->index(); + } + + public function testIndexSetsPreviewEnabledFalse(): void { + $this->preview->expects($this->once()) + ->method('isMimeSupported') + ->with('application/vnd.oasis.opendocument.text') + ->willReturn(false); + + $this->initialState->expects($this->exactly(2)) + ->method('provideInitialState') + ->withConsecutive( + ['previewEnabled', false], + ['overview_config', ['overview_grid_view' => false]] + ); + + $this->controller->index(); + } +} diff --git a/webpack.js b/webpack.js index c2d77b49ae..3ce6d64113 100644 --- a/webpack.js +++ b/webpack.js @@ -13,6 +13,7 @@ webpackConfig.entry = { 'init-viewer': path.join(__dirname, 'src', 'init-viewer.js'), fileActions: path.join(__dirname, 'src', 'file-actions.js'), document: path.join(__dirname, 'src', 'document.js'), + overview: path.join(__dirname, 'src', 'overview.js'), admin: path.join(__dirname, 'src', 'admin.js'), personal: path.join(__dirname, 'src', 'personal.js'), reference: path.join(__dirname, 'src', 'reference.js'),