]> source.dussan.org Git - nextcloud-server.git/commitdiff
enh(files): Add modal to set filename before creating new files in the fileslist
authorFerdinand Thiessen <opensource@fthiessen.de>
Sun, 21 Jan 2024 00:29:24 +0000 (01:29 +0100)
committerFerdinand Thiessen <opensource@fthiessen.de>
Fri, 9 Feb 2024 00:06:42 +0000 (01:06 +0100)
* Reactive `openfile` query

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
apps/files/src/components/FileEntry.vue
apps/files/src/components/FilesListVirtual.vue
apps/files/src/components/NewNodeDialog.vue [new file with mode: 0644]
apps/files/src/init-templates.ts [deleted file]
apps/files/src/init.ts
apps/files/src/newMenu/newFolder.ts
apps/files/src/newMenu/newFromTemplate.ts [new file with mode: 0644]
apps/files/src/newMenu/newTemplatesFolder.ts [new file with mode: 0644]
apps/files/src/utils/newNodeDialog.ts [new file with mode: 0644]
apps/files/src/views/FilesList.vue

index 0ccd5622a5eaf147b7ca06bc31c88f00a15e69ee..973e1de667fd14ff0c1baa01b3ce2a2d79c1a2a9 100644 (file)
   -->
 
 <template>
-       <tr :class="{'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
+       <tr :class="{
+                       'files-list__row--dragover': dragover,
+                       'files-list__row--loading': isLoading,
+                       'files-list__row--active': isActive,
+               }"
                data-cy-files-list-row
                :data-cy-files-list-row-fileid="fileid"
                :data-cy-files-list-row-name="source.basename"
 
 <script lang="ts">
 import { defineComponent } from 'vue'
-import { formatFileSize } from '@nextcloud/files'
+import { Permission, formatFileSize } from '@nextcloud/files'
 import moment from '@nextcloud/moment'
 
 import { useActionsMenuStore } from '../store/actionsmenu.ts'
@@ -232,6 +236,13 @@ export default defineComponent({
                        }
                        return ''
                },
+
+               /**
+                * This entry is the current active node
+                */
+               isActive() {
+                       return this.fileid === this.currentFileId?.toString?.()
+               },
        },
 
        methods: {
index afb2dbd888abdf9cd6419a465eb50e4d0e81b069..b6a11391dc1f417b1ceb6ee244b20b9e0a7aa7b9 100644 (file)
@@ -139,6 +139,7 @@ export default defineComponent({
                        FileEntryGrid,
                        headers: getFileListHeaders(),
                        scrollToIndex: 0,
+                       openFileId: null as number|null,
                }
        },
 
@@ -151,6 +152,14 @@ export default defineComponent({
                        return parseInt(this.$route.params.fileid) || null
                },
 
+               /**
+                * If the current `fileId` should be opened
+                * The state of the `openfile` query param
+                */
+               openFile() {
+                       return !!this.$route.query.openfile
+               },
+
                summary() {
                        return getSummaryFor(this.nodes)
                },
@@ -199,6 +208,12 @@ export default defineComponent({
                fileId(fileId) {
                        this.scrollToFile(fileId, false)
                },
+
+               openFile(open: boolean) {
+                       if (open) {
+                               this.$nextTick(() => this.handleOpenFile(this.fileId))
+                       }
+               },
        },
 
        mounted() {
@@ -206,9 +221,11 @@ export default defineComponent({
                const mainContent = window.document.querySelector('main.app-content') as HTMLElement
                mainContent.addEventListener('dragover', this.onDragOver)
 
-               this.scrollToFile(this.fileId)
-               this.openSidebarForFile(this.fileId)
-               this.handleOpenFile()
+               // handle initially opening a given file
+               const { id } = loadState<{ id?: number }>('files', 'openFileInfo', {})
+               this.scrollToFile(id ?? this.fileId)
+               this.openSidebarForFile(id ?? this.fileId)
+               this.handleOpenFile(id ?? null)
        },
 
        beforeDestroy() {
@@ -241,18 +258,22 @@ export default defineComponent({
                        }
                },
 
-               handleOpenFile() {
-                       const openFileInfo = loadState('files', 'openFileInfo', {}) as ({ id?: number })
-                       if (openFileInfo === undefined) {
+               /**
+                * Handle opening a file (e.g. by ?openfile=true)
+                * @param fileId File to open
+                */
+               handleOpenFile(fileId: number|null) {
+                       if (fileId === null || this.openFileId === fileId) {
                                return
                        }
 
-                       const node = this.nodes.find(n => n.fileid === openFileInfo.id) as NcNode
+                       const node = this.nodes.find(n => n.fileid === fileId) as NcNode
                        if (node === undefined || node.type === FileType.Folder) {
                                return
                        }
 
                        logger.debug('Opening file ' + node.path, { node })
+                       this.openFileId = fileId
                        getFileActions()
                                .filter(action => !action.enabled || action.enabled([node], this.currentView))
                                .sort((a, b) => (a.order || 0) - (b.order || 0))
diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue
new file mode 100644 (file)
index 0000000..38337dd
--- /dev/null
@@ -0,0 +1,149 @@
+<!--
+  - @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+  -
+  - @author Ferdinand Thiessen <opensource@fthiessen.de>
+  -
+  - @license AGPL-3.0-or-later
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+<template>
+       <NcDialog :name="name"
+               :open="open"
+               close-on-click-outside
+               out-transition
+               @update:open="onClose">
+               <template #actions>
+                       <NcButton type="primary"
+                               :disabled="!isUniqueName"
+                               @click="onCreate">
+                               {{ t('files', 'Create') }}
+                       </NcButton>
+               </template>
+               <form @submit.prevent="onCreate">
+                       <NcTextField ref="input"
+                               :error="!isUniqueName"
+                               :helper-text="errorMessage"
+                               :label="label"
+                               :value.sync="localDefaultName" />
+               </form>
+       </NcDialog>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+
+import { defineComponent } from 'vue'
+import { translate as t } from '@nextcloud/l10n'
+import { getUniqueName } from '../utils/fileUtils'
+
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+
+interface ICanFocus {
+       focus: () => void
+}
+
+export default defineComponent({
+       name: 'NewNodeDialog',
+       components: {
+               NcButton,
+               NcDialog,
+               NcTextField,
+       },
+       props: {
+               /**
+                * The name to be used by default
+                */
+               defaultName: {
+                       type: String,
+                       default: t('files', 'New folder'),
+               },
+               /**
+                * Other files that are in the current directory
+                */
+               otherNames: {
+                       type: Array as PropType<string[]>,
+                       default: () => [],
+               },
+               /**
+                * Open state of the dialog
+                */
+               open: {
+                       type: Boolean,
+                       default: true,
+               },
+               /**
+                * Dialog name
+                */
+               name: {
+                       type: String,
+                       default: t('files', 'Create new folder'),
+               },
+               /**
+                * Input label
+                */
+               label: {
+                       type: String,
+                       default: t('files', 'Folder name'),
+               },
+       },
+       emits: {
+               close: (name: string|null) => name === null || name,
+       },
+       data() {
+               return {
+                       localDefaultName: this.defaultName || t('files', 'New folder'),
+               }
+       },
+       computed: {
+               errorMessage() {
+                       if (this.isUniqueName) {
+                               return ''
+                       } else {
+                               return t('files', 'A file or folder with that name already exists.')
+                       }
+               },
+               uniqueName() {
+                       return getUniqueName(this.localDefaultName, this.otherNames)
+               },
+               isUniqueName() {
+                       return this.localDefaultName === this.uniqueName
+               },
+       },
+       watch: {
+               defaultName() {
+                       this.localDefaultName = this.defaultName || t('files', 'New folder')
+               },
+       },
+       mounted() {
+               // on mounted lets use the unique name
+               this.localDefaultName = this.uniqueName
+               this.$nextTick(() => (this.$refs.input as unknown as ICanFocus)?.focus?.())
+       },
+       methods: {
+               t,
+               onCreate() {
+                       this.$emit('close', this.localDefaultName)
+               },
+               onClose(state: boolean) {
+                       if (!state) {
+                               this.$emit('close', null)
+                       }
+               },
+       },
+})
+</script>
diff --git a/apps/files/src/init-templates.ts b/apps/files/src/init-templates.ts
deleted file mode 100644 (file)
index 6803143..0000000
+++ /dev/null
@@ -1,149 +0,0 @@
-/**
- * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-import type { Entry } from '@nextcloud/files'
-import type { TemplateFile } from './types'
-
-import { Folder, Node, Permission, addNewFileMenuEntry, removeNewFileMenuEntry } from '@nextcloud/files'
-import { generateOcsUrl } from '@nextcloud/router'
-import { getLoggerBuilder } from '@nextcloud/logger'
-import { join } from 'path'
-import { loadState } from '@nextcloud/initial-state'
-import { showError } from '@nextcloud/dialogs'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import axios from '@nextcloud/axios'
-import Vue from 'vue'
-
-import PlusSvg from '@mdi/svg/svg/plus.svg?raw'
-
-import TemplatePickerView from './views/TemplatePicker.vue'
-import { getUniqueName } from './utils/fileUtils.ts'
-import { getCurrentUser } from '@nextcloud/auth'
-
-// Set up logger
-const logger = getLoggerBuilder()
-       .setApp('files')
-       .detectUser()
-       .build()
-
-// Add translates functions
-Vue.mixin({
-       methods: {
-               t,
-               n,
-       },
-})
-
-// Create document root
-const TemplatePickerRoot = document.createElement('div')
-TemplatePickerRoot.id = 'template-picker'
-document.body.appendChild(TemplatePickerRoot)
-
-// Retrieve and init templates
-let templates = loadState<TemplateFile[]>('files', 'templates', [])
-let templatesPath = loadState('files', 'templates_path', false)
-logger.debug('Templates providers', { templates })
-logger.debug('Templates folder', { templatesPath })
-
-// Init vue app
-const View = Vue.extend(TemplatePickerView)
-const TemplatePicker = new View({
-       name: 'TemplatePicker',
-       propsData: {
-               logger,
-       },
-})
-TemplatePicker.$mount('#template-picker')
-if (!templatesPath) {
-       logger.debug('Templates folder not initialized')
-       addNewFileMenuEntry({
-               id: 'template-picker',
-               displayName: t('files', 'Create new templates folder'),
-               iconSvgInline: PlusSvg,
-               order: 10,
-               enabled(context: Folder): boolean {
-                       // Allow creation on your own folders only
-                       if (context.owner !== getCurrentUser()?.uid) {
-                               return false
-                       }
-                       return (context.permissions & Permission.CREATE) !== 0
-               },
-               handler(context: Folder, content: Node[]) {
-                       // Check for conflicts
-                       const contentNames = content.map((node: Node) => node.basename)
-                       const name = getUniqueName(t('files', 'Templates'), contentNames)
-
-                       // Create the template folder
-                       initTemplatesFolder(context, name)
-
-                       // Remove the menu entry
-                       removeNewFileMenuEntry('template-picker')
-               },
-       } as Entry)
-}
-
-// Init template files menu
-templates.forEach((provider, index) => {
-       addNewFileMenuEntry({
-               id: `template-new-${provider.app}-${index}`,
-               displayName: provider.label,
-               // TODO: migrate to inline svg
-               iconClass: provider.iconClass || 'icon-file',
-               enabled(context: Folder): boolean {
-                       return (context.permissions & Permission.CREATE) !== 0
-               },
-               order: 11,
-               handler(context: Folder, content: Node[]) {
-                       // Check for conflicts
-                       const contentNames = content.map((node: Node) => node.basename)
-                       const name = getUniqueName(provider.label + provider.extension, contentNames)
-
-                       // Create the file
-                       TemplatePicker.open(name, provider)
-               },
-       } as Entry)
-})
-
-// Init template folder
-const initTemplatesFolder = async function(directory: Folder, name: string) {
-       const templatePath = join(directory.path, name)
-       try {
-               logger.debug('Initializing the templates directory', { templatePath })
-               const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), {
-                       templatePath,
-                       copySystemTemplates: true,
-               })
-
-               // Go to template directory
-               window.OCP.Files.Router.goToRoute(
-                       null, // use default route
-                       { view: 'files', fileid: undefined },
-                       { dir: templatePath },
-               )
-
-               templates = response.data.ocs.data.templates
-               templatesPath = response.data.ocs.data.template_path
-       } catch (error) {
-               logger.error('Unable to initialize the templates directory')
-               showError(t('files', 'Unable to initialize the templates directory'))
-       }
-}
index 9f463244d9154e15bfe4c0e9fe7140d65d1ba195..c3b4b570e12ca5aa6c7969e672dbe2682d49931b 100644 (file)
@@ -31,14 +31,15 @@ import { action as openInFilesAction } from './actions/openInFilesAction'
 import { action as renameAction } from './actions/renameAction'
 import { action as sidebarAction } from './actions/sidebarAction'
 import { action as viewInFolderAction } from './actions/viewInFolderAction'
-import { entry as newFolderEntry } from './newMenu/newFolder'
+import { entry as newFolderEntry } from './newMenu/newFolder.ts'
+import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts'
+import { registerTemplateEntries } from './newMenu/newFromTemplate.ts'
 
 import registerFavoritesView from './views/favorites'
 import registerRecentView from './views/recent'
 import registerFilesView from './views/files'
 import registerPreviewServiceWorker from './services/ServiceWorker.js'
 
-import './init-templates'
 
 import { initLivePhotos } from './services/LivePhotos'
 
@@ -56,6 +57,8 @@ registerFileAction(viewInFolderAction)
 
 // Register new menu entry
 addNewFileMenuEntry(newFolderEntry)
+addNewFileMenuEntry(newTemplatesFolder)
+registerTemplateEntries()
 
 // Register files views
 registerFavoritesView()
index 37dcf6d3d895f87aa818cd797fb87f1b6ab3811e..64ab8004e78cf2539da2769df3dd6709c2890396 100644 (file)
@@ -31,7 +31,7 @@ import axios from '@nextcloud/axios'
 
 import FolderPlusSvg from '@mdi/svg/svg/folder-plus.svg?raw'
 
-import { getUniqueName } from '../utils/fileUtils.ts'
+import { newNodeName } from '../utils/newNodeDialog'
 import logger from '../logger'
 
 type createFolderResponse = {
@@ -63,23 +63,27 @@ export const entry = {
        iconSvgInline: FolderPlusSvg,
        order: 0,
        async handler(context: Folder, content: Node[]) {
-               const contentNames = content.map((node: Node) => node.basename)
-               const name = getUniqueName(t('files', 'New folder'), contentNames)
-               const { fileid, source } = await createNewFolder(context, name)
+               const name = await newNodeName(t('files', 'New folder'), content)
+               if (name !== null) {
+                       const { fileid, source } = await createNewFolder(context, name)
+                       // Create the folder in the store
+                       const folder = new Folder({
+                               source,
+                               id: fileid,
+                               mtime: new Date(),
+                               owner: getCurrentUser()?.uid || null,
+                               permissions: Permission.ALL,
+                               root: context?.root || '/files/' + getCurrentUser()?.uid,
+                       })
 
-               // Create the folder in the store
-               const folder = new Folder({
-                       source,
-                       id: fileid,
-                       mtime: new Date(),
-                       owner: getCurrentUser()?.uid || null,
-                       permissions: Permission.ALL,
-                       root: context?.root || '/files/' + getCurrentUser()?.uid,
-               })
-
-               showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) }))
-               logger.debug('Created new folder', { folder, source })
-               emit('files:node:created', folder)
-               emit('files:node:rename', folder)
+                       showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) }))
+                       logger.debug('Created new folder', { folder, source })
+                       emit('files:node:created', folder)
+                       window.OCP.Files.Router.goToRoute(
+                               null, // use default route
+                               { view: 'files', fileid: folder.fileid },
+                               { dir: context.path },
+                       )
+               }
        },
 } as Entry
diff --git a/apps/files/src/newMenu/newFromTemplate.ts b/apps/files/src/newMenu/newFromTemplate.ts
new file mode 100644 (file)
index 0000000..5e69181
--- /dev/null
@@ -0,0 +1,88 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @author Julius Härtl <jus@bitgrid.net>
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import type { Entry } from '@nextcloud/files'
+import type { ComponentInstance } from 'vue'
+import type { TemplateFile } from '../types.ts'
+
+import { Folder, Node, Permission, addNewFileMenuEntry } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import { newNodeName } from '../utils/newNodeDialog'
+import { translate as t } from '@nextcloud/l10n'
+import Vue, { defineAsyncComponent } from 'vue'
+
+// async to reduce bundle size
+const TemplatePickerVue = defineAsyncComponent(() => import('../views/TemplatePicker.vue'))
+let TemplatePicker: ComponentInstance & { open: (n: string, t: TemplateFile) => void } | null = null
+
+const getTemplatePicker = async () => {
+       if (TemplatePicker === null) {
+               // Create document root
+               const mountingPoint = document.createElement('div')
+               mountingPoint.id = 'template-picker'
+               document.body.appendChild(mountingPoint)
+
+               // Init vue app
+               TemplatePicker = new Vue({
+                       render: (h) => h(TemplatePickerVue, { ref: 'picker' }),
+                       methods: { open(...args) { this.$refs.picker.open(...args) } },
+                       el: mountingPoint,
+               })
+       }
+       return TemplatePicker
+}
+
+/**
+ * Register all new-file-menu entries for all template providers
+ */
+export function registerTemplateEntries() {
+       const templates = loadState<TemplateFile[]>('files', 'templates', [])
+
+       // Init template files menu
+       templates.forEach((provider, index) => {
+               addNewFileMenuEntry({
+                       id: `template-new-${provider.app}-${index}`,
+                       displayName: provider.label,
+                       // TODO: migrate to inline svg
+                       iconClass: provider.iconClass || 'icon-file',
+                       enabled(context: Folder): boolean {
+                               return (context.permissions & Permission.CREATE) !== 0
+                       },
+                       order: 11,
+                       async handler(context: Folder, content: Node[]) {
+                               const templatePicker = getTemplatePicker()
+                               const name = await newNodeName(`${provider.label}${provider.extension}`, content, {
+                                       label: t('files', 'Filename'),
+                                       name: provider.label,
+                               })
+
+                               if (name !== null) {
+                                       // Create the file
+                                       const picker = await templatePicker
+                                       picker.open(name, provider)
+                               }
+                       },
+               } as Entry)
+       })
+}
diff --git a/apps/files/src/newMenu/newTemplatesFolder.ts b/apps/files/src/newMenu/newTemplatesFolder.ts
new file mode 100644 (file)
index 0000000..fafee55
--- /dev/null
@@ -0,0 +1,100 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @author Julius Härtl <jus@bitgrid.net>
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import type { Entry, Folder, Node } from '@nextcloud/files'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { showError } from '@nextcloud/dialogs'
+import { Permission, removeNewFileMenuEntry } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { generateOcsUrl } from '@nextcloud/router'
+import { join } from 'path'
+import { newNodeName } from '../utils/newNodeDialog'
+
+import PlusSvg from '@mdi/svg/svg/plus.svg?raw'
+import axios from '@nextcloud/axios'
+import logger from '../logger.js'
+
+let templatesPath = loadState<string|false>('files', 'templates_path', false)
+logger.debug('Initial templates folder', { templatesPath })
+
+/**
+ * Init template folder
+ * @param directory Folder where to create the templates folder
+ * @param name Name to use or the templates folder
+ */
+const initTemplatesFolder = async function(directory: Folder, name: string) {
+       const templatePath = join(directory.path, name)
+       try {
+               logger.debug('Initializing the templates directory', { templatePath })
+               const { data } = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), {
+                       templatePath,
+                       copySystemTemplates: true,
+               })
+
+               // Go to template directory
+               window.OCP.Files.Router.goToRoute(
+                       null, // use default route
+                       { view: 'files', fileid: undefined },
+                       { dir: templatePath },
+               )
+
+               logger.info('Created new templates folder', {
+                       ...data.ocs.data,
+               })
+               templatesPath = data.ocs.data.templates_path as string
+       } catch (error) {
+               logger.error('Unable to initialize the templates directory')
+               showError(t('files', 'Unable to initialize the templates directory'))
+       }
+}
+
+export const entry = {
+       id: 'template-picker',
+       displayName: t('files', 'Create new templates folder'),
+       iconSvgInline: PlusSvg,
+       order: 10,
+       enabled(context: Folder): boolean {
+               // Templates folder already initialized
+               if (templatesPath) {
+                       return false
+               }
+               // Allow creation on your own folders only
+               if (context.owner !== getCurrentUser()?.uid) {
+                       return false
+               }
+               return (context.permissions & Permission.CREATE) !== 0
+       },
+       async handler(context: Folder, content: Node[]) {
+               const name = await newNodeName(t('files', 'Templates'), content, { name: t('files', 'New template folder') })
+
+               if (name !== null) {
+                       // Create the template folder
+                       initTemplatesFolder(context, name)
+
+                       // Remove the menu entry
+                       removeNewFileMenuEntry('template-picker')
+               }
+       },
+} as Entry
diff --git a/apps/files/src/utils/newNodeDialog.ts b/apps/files/src/utils/newNodeDialog.ts
new file mode 100644 (file)
index 0000000..f53694f
--- /dev/null
@@ -0,0 +1,57 @@
+/**
+ * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import type { Node } from '@nextcloud/files'
+import { spawnDialog } from '@nextcloud/dialogs'
+import NewNodeDialog from '../components/NewNodeDialog.vue'
+
+interface ILabels {
+       /**
+        * Dialog heading, defaults to "New folder name"
+        */
+       name?: string
+       /**
+        * Label for input box, defaults to "New folder"
+        */
+       label?: string
+}
+
+/**
+ * Ask user for file or folder name
+ * @param defaultName Default name to use
+ * @param folderContent Nodes with in the current folder to check for unique name
+ * @param labels Labels to set on the dialog
+ * @return string if successfull otherwise null if aborted
+ */
+export function newNodeName(defaultName: string, folderContent: Node[], labels: ILabels = {}) {
+       const contentNames = folderContent.map((node: Node) => node.basename)
+
+       return new Promise<string|null>((resolve) => {
+               spawnDialog(NewNodeDialog, {
+                       ...labels,
+                       defaultName,
+                       otherNames: contentNames,
+               }, (folderName) => {
+                       resolve(folderName as string|null)
+               })
+       })
+}
index 19e9b8e86d286b66143dd5dc518d381ae1a6ba67..4e80379f632ba59834fbc79da3861e87953086c7 100644 (file)
@@ -566,15 +566,20 @@ export default defineComponent({
                /**
                 * Refreshes the current folder on update.
                 *
-                * @param {Node} node is the file/folder being updated.
+                * @param node is the file/folder being updated.
                 */
-               onUpdatedNode(node) {
+               onUpdatedNode(node?: Node) {
                        if (node?.fileid === this.currentFolder?.fileid) {
                                this.fetchContent()
                        }
                },
 
                openSharingSidebar() {
+                       if (!this.currentFolder) {
+                               logger.debug('No current folder found for opening sharing sidebar')
+                               return
+                       }
+
                        if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
                                window.OCA.Files.Sidebar.setActiveTab('sharing')
                        }