Browse Source
Add template picker
Add template picker
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>pull/25090/head
committed by
Julius Härtl
No known key found for this signature in database
GPG Key ID: 4C614C6ED2CDE6DF
11 changed files with 666 additions and 5 deletions
-
2apps/files/lib/Controller/TemplateController.php
-
1apps/files/lib/Controller/ViewController.php
-
3apps/files/lib/Event/LoadAdditionalScriptsEvent.php
-
203apps/files/src/components/TemplatePreview.vue
-
92apps/files/src/templates.js
-
42apps/files/src/utils/davUtils.js
-
53apps/files/src/utils/fileUtils.js
-
268apps/files/src/views/TemplatePicker.vue
-
3apps/files/webpack.js
-
2lib/private/Files/Template/TemplateManager.php
-
2lib/public/Files/Template/ITemplateManager.php
@ -0,0 +1,203 @@ |
|||
<!-- |
|||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> |
|||
- |
|||
- @author John Molakvoæ <skjnldsv@protonmail.com> |
|||
- |
|||
- @license GNU AGPL version 3 or any later version |
|||
- |
|||
- 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> |
|||
<li class="template-picker__item"> |
|||
<input :id="id" |
|||
:checked="checked" |
|||
type="radio" |
|||
class="radio" |
|||
name="template-picker" |
|||
@change="onCheck"> |
|||
|
|||
<label :for="id" class="template-picker__label"> |
|||
<div class="template-picker__preview"> |
|||
<img class="template-picker__image" |
|||
:class="failedPreview ? 'template-picker__image--failed' : ''" |
|||
:src="realPreviewUrl" |
|||
alt="" |
|||
draggable="false" |
|||
@error="onFailure"> |
|||
</div> |
|||
|
|||
<span class="template-picker__title"> |
|||
{{ basename }} |
|||
</span> |
|||
</label> |
|||
</li> |
|||
</template> |
|||
|
|||
<script> |
|||
import { generateUrl } from '@nextcloud/router' |
|||
import { encodeFilePath } from '../utils/fileUtils' |
|||
import { getToken, isPublic } from '../utils/davUtils' |
|||
|
|||
// preview width generation |
|||
const previewWidth = 256 |
|||
|
|||
export default { |
|||
name: 'TemplatePreview', |
|||
inheritAttrs: false, |
|||
|
|||
props: { |
|||
basename: { |
|||
type: String, |
|||
required: true, |
|||
}, |
|||
checked: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
fileid: { |
|||
type: [String, Number], |
|||
required: true, |
|||
}, |
|||
filename: { |
|||
type: String, |
|||
required: true, |
|||
}, |
|||
previewUrl: { |
|||
type: String, |
|||
default: null, |
|||
}, |
|||
hasPreview: { |
|||
type: Boolean, |
|||
default: true, |
|||
}, |
|||
mime: { |
|||
type: String, |
|||
required: true, |
|||
}, |
|||
ratio: { |
|||
type: Number, |
|||
default: null, |
|||
}, |
|||
}, |
|||
|
|||
data() { |
|||
return { |
|||
failedPreview: false, |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
id() { |
|||
return `template-picker-${this.fileid}` |
|||
}, |
|||
|
|||
realPreviewUrl() { |
|||
// If original preview failed, fallback to mime icon |
|||
if (this.failedPreview && this.mimeIcon) { |
|||
return generateUrl(this.mimeIcon) |
|||
} |
|||
|
|||
if (this.previewUrl) { |
|||
return this.previewUrl |
|||
} |
|||
// TODO: find a nicer standard way of doing this? |
|||
if (isPublic()) { |
|||
return generateUrl(`/apps/files_sharing/publicpreview/${getToken()}?fileId=${this.fileid}&file=${encodeFilePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`) |
|||
} |
|||
return generateUrl(`/core/preview?fileId=${this.fileid}&x=${previewWidth}&y=${previewWidth}&a=1`) |
|||
}, |
|||
|
|||
mimeIcon() { |
|||
return OC.MimeType.getIconUrl(this.mime) |
|||
}, |
|||
}, |
|||
|
|||
methods: { |
|||
onCheck() { |
|||
this.$emit('check', this.fileid) |
|||
}, |
|||
onFailure() { |
|||
this.failedPreview = true |
|||
}, |
|||
}, |
|||
} |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
|
|||
.template-picker { |
|||
&__item { |
|||
display: flex; |
|||
} |
|||
|
|||
&__label { |
|||
display: flex; |
|||
// Align in the middle of the grid |
|||
align-items: center; |
|||
flex: 1 1; |
|||
flex-direction: column; |
|||
margin: var(--margin); |
|||
|
|||
&, * { |
|||
cursor: pointer; |
|||
user-select: none; |
|||
} |
|||
|
|||
&::before { |
|||
display: none !important; |
|||
} |
|||
} |
|||
|
|||
&__preview { |
|||
display: flex; |
|||
overflow: hidden; |
|||
// Stretch so all entries are the same width |
|||
flex: 1 1; |
|||
width: var(--width); |
|||
min-height: var(--width); |
|||
max-height: var(--height); |
|||
padding: var(--margin); |
|||
border: var(--border) solid var(--color-border); |
|||
border-radius: var(--border-radius-large); |
|||
|
|||
input:checked + label > & { |
|||
border-color: var(--color-primary); |
|||
} |
|||
} |
|||
|
|||
&__image { |
|||
max-width: 100%; |
|||
background-color: var(--color-main-background); |
|||
|
|||
&--failed { |
|||
width: calc(var(--margin) * 8); |
|||
// Center mime icon |
|||
margin: auto; |
|||
background-color: transparent !important; |
|||
} |
|||
} |
|||
|
|||
&__title { |
|||
overflow: hidden; |
|||
// also count preview border |
|||
max-width: calc(var(--width) + 2*2px); |
|||
padding: var(--margin); |
|||
white-space: nowrap; |
|||
text-overflow: ellipsis; |
|||
} |
|||
} |
|||
|
|||
</style> |
|||
@ -0,0 +1,92 @@ |
|||
/** |
|||
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @author John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @license GNU AGPL version 3 or any later version |
|||
* |
|||
* 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 { getLoggerBuilder } from '@nextcloud/logger' |
|||
import { loadState } from '@nextcloud/initial-state' |
|||
import { translate as t, translatePlural as n } from '@nextcloud/l10n' |
|||
import Vue from 'vue' |
|||
|
|||
import TemplatePickerView from './views/TemplatePicker' |
|||
|
|||
// 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
|
|||
const templates = loadState('files', 'templates', []) |
|||
logger.debug('Templates providers', templates) |
|||
|
|||
// Init vue app
|
|||
const View = Vue.extend(TemplatePickerView) |
|||
const TemplatePicker = new View({ |
|||
name: 'TemplatePicker', |
|||
propsData: { |
|||
logger, |
|||
}, |
|||
}) |
|||
TemplatePicker.$mount('#template-picker') |
|||
|
|||
// Init template engine after load
|
|||
window.addEventListener('DOMContentLoaded', function() { |
|||
// Init template files menu
|
|||
templates.forEach((provider, index) => { |
|||
|
|||
const newTemplatePlugin = { |
|||
attach(menu) { |
|||
const fileList = menu.fileList |
|||
|
|||
// only attach to main file list, public view is not supported yet
|
|||
if (fileList.id !== 'files' && fileList.id !== 'files.public') { |
|||
return |
|||
} |
|||
|
|||
// register the new menu entry
|
|||
menu.addMenuEntry({ |
|||
id: `template-new-${provider.app}-${index}`, |
|||
displayName: provider.label, |
|||
templateName: provider.label + provider.extension, |
|||
iconClass: provider.iconClass || 'icon-file', |
|||
fileType: 'file', |
|||
actionHandler(name) { |
|||
TemplatePicker.open(name, provider) |
|||
}, |
|||
}) |
|||
}, |
|||
} |
|||
OC.Plugins.register('OCA.Files.NewFileMenu', newTemplatePlugin) |
|||
}) |
|||
}) |
|||
@ -0,0 +1,42 @@ |
|||
/** |
|||
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @author John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @license GNU AGPL version 3 or any later version |
|||
* |
|||
* 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 { generateRemoteUrl } from '@nextcloud/router' |
|||
import { getCurrentUser } from '@nextcloud/auth' |
|||
|
|||
const getRootPath = function() { |
|||
if (getCurrentUser()) { |
|||
return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) |
|||
} else { |
|||
return generateRemoteUrl('webdav').replace('/remote.php', '/public.php') |
|||
} |
|||
} |
|||
|
|||
const isPublic = function() { |
|||
return !getCurrentUser() |
|||
} |
|||
|
|||
const getToken = function() { |
|||
return document.getElementById('sharingToken') && document.getElementById('sharingToken').value |
|||
} |
|||
|
|||
export { getRootPath, getToken, isPublic } |
|||
@ -0,0 +1,53 @@ |
|||
/** |
|||
* @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @author John Molakvoæ <skjnldsv@protonmail.com> |
|||
* |
|||
* @license GNU AGPL version 3 or any later version |
|||
* |
|||
* 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/>.
|
|||
* |
|||
*/ |
|||
|
|||
/** |
|||
* Get an url encoded path |
|||
* |
|||
* @param {String} path the full path |
|||
* @returns {string} url encoded file path |
|||
*/ |
|||
const encodeFilePath = function(path) { |
|||
const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/') |
|||
let relativePath = '' |
|||
pathSections.forEach((section) => { |
|||
if (section !== '') { |
|||
relativePath += '/' + encodeURIComponent(section) |
|||
} |
|||
}) |
|||
return relativePath |
|||
} |
|||
|
|||
/** |
|||
* Extract dir and name from file path |
|||
* |
|||
* @param {String} path the full path |
|||
* @returns {String[]} [dirPath, fileName] |
|||
*/ |
|||
const extractFilePaths = function(path) { |
|||
const pathSections = path.split('/') |
|||
const fileName = pathSections[pathSections.length - 1] |
|||
const dirPath = pathSections.slice(0, pathSections.length - 1).join('/') |
|||
return [dirPath, fileName] |
|||
} |
|||
|
|||
export { encodeFilePath, extractFilePaths } |
|||
@ -0,0 +1,268 @@ |
|||
<!-- |
|||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> |
|||
- |
|||
- @author John Molakvoæ <skjnldsv@protonmail.com> |
|||
- |
|||
- @license GNU AGPL version 3 or any later version |
|||
- |
|||
- 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> |
|||
<Modal v-if="opened" |
|||
:clear-view-delay="-1" |
|||
class="templates-picker" |
|||
size="large" |
|||
@close="close"> |
|||
<form class="templates-picker__form" |
|||
:style="style" |
|||
@submit.prevent.stop="onSubmit"> |
|||
<h3>{{ t('files', 'Pick a template') }}</h3> |
|||
|
|||
<!-- Templates list --> |
|||
<ul class="templates-picker__list"> |
|||
<TemplatePreview |
|||
v-bind="emptyTemplate" |
|||
:checked="checked === emptyTemplate.fileid" |
|||
@check="onCheck" /> |
|||
|
|||
<TemplatePreview |
|||
v-for="template in provider.templates" |
|||
:key="template.fileid" |
|||
v-bind="template" |
|||
:checked="checked === template.fileid" |
|||
:ratio="provider.ratio" |
|||
@check="onCheck" /> |
|||
</ul> |
|||
|
|||
<!-- Cancel and submit --> |
|||
<div class="templates-picker__buttons"> |
|||
<button @click="close"> |
|||
{{ t('files', 'Cancel') }} |
|||
</button> |
|||
<input type="submit" |
|||
class="primary" |
|||
:value="t('files', 'Create')" |
|||
:aria-label="t('files', 'Create a new file with the ')"> |
|||
</div> |
|||
</form> |
|||
|
|||
<EmptyContent class="templates-picker__loading" v-if="loading" icon="icon-loading"> |
|||
{{ t('files', 'Creating file') }} |
|||
</EmptyContent> |
|||
</Modal> |
|||
</template> |
|||
|
|||
<script> |
|||
import { generateOcsUrl } from '@nextcloud/router' |
|||
import { showError } from '@nextcloud/dialogs' |
|||
|
|||
import axios from '@nextcloud/axios' |
|||
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' |
|||
import Modal from '@nextcloud/vue/dist/Components/Modal' |
|||
|
|||
import TemplatePreview from '../components/TemplatePreview' |
|||
|
|||
const border = 2 |
|||
const margin = 8 |
|||
const width = margin * 20 |
|||
|
|||
export default { |
|||
name: 'TemplatePicker', |
|||
|
|||
components: { |
|||
EmptyContent, |
|||
Modal, |
|||
TemplatePreview, |
|||
}, |
|||
|
|||
props: { |
|||
logger: { |
|||
type: Object, |
|||
required: true, |
|||
}, |
|||
}, |
|||
|
|||
data() { |
|||
return { |
|||
// Check empty template by default |
|||
checked: -1, |
|||
loading: false, |
|||
name: null, |
|||
opened: false, |
|||
provider: null, |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
emptyTemplate() { |
|||
return { |
|||
basename: t('files', 'Blank'), |
|||
fileid: -1, |
|||
filename: this.t('files', 'Blank'), |
|||
hasPreview: false, |
|||
mime: this.provider?.mimetypes[0] || this.provider?.mimetypes, |
|||
} |
|||
}, |
|||
|
|||
selectedTemplate() { |
|||
return this.provider.templates.find(template => template.fileid === this.checked) |
|||
}, |
|||
|
|||
/** |
|||
* Style css vars bin,d |
|||
* @returns {Object} |
|||
*/ |
|||
style() { |
|||
return { |
|||
'--margin': margin + 'px', |
|||
'--width': width + 'px', |
|||
'--border': border + 'px', |
|||
'--fullwidth': width + 2 * margin + 2 * border + 'px', |
|||
'--height': this.ratio ? width * this.ratio + 'px' : null, |
|||
} |
|||
}, |
|||
}, |
|||
|
|||
methods: { |
|||
/** |
|||
* Open the picker |
|||
* @param {string} name the file name to create |
|||
* @param {object} provider the template provider picked |
|||
*/ |
|||
open(name, provider) { |
|||
this.checked = this.emptyTemplate.fileid |
|||
this.name = name |
|||
this.opened = true |
|||
this.provider = provider |
|||
}, |
|||
|
|||
/** |
|||
* Close the picker and reset variables |
|||
*/ |
|||
close() { |
|||
this.checked = this.emptyTemplate.fileid |
|||
this.loading = false |
|||
this.name = null |
|||
this.opened = false |
|||
this.provider = null |
|||
}, |
|||
|
|||
/** |
|||
* Manages the radio template picker change |
|||
* @param {number} fileid the selected template file id |
|||
*/ |
|||
onCheck(fileid) { |
|||
this.checked = fileid |
|||
}, |
|||
|
|||
async onSubmit() { |
|||
this.loading = true |
|||
const currentDirectory = this.getCurrentDirectory() |
|||
const fileList = OCA?.Files?.App?.currentFileList |
|||
|
|||
try { |
|||
const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates', 2) + 'create', { |
|||
filePath: `${currentDirectory}/${this.name}`, |
|||
templatePath: this.selectedTemplate?.filename, |
|||
templateType: this.selectedTemplate?.templateType, |
|||
}) |
|||
|
|||
const fileInfo = response.data.ocs.data |
|||
this.logger.debug('Created new file', fileInfo) |
|||
|
|||
// Run default action |
|||
const fileAction = OCA.Files.fileActions.getDefaultFileAction(fileInfo.mime, 'file', OC.PERMISSION_ALL) |
|||
fileAction.action(fileInfo.basename, { |
|||
$file: null, |
|||
dir: currentDirectory, |
|||
fileList, |
|||
fileActions: fileList?.fileActions, |
|||
}) |
|||
|
|||
// Reload files list |
|||
fileList?.reload?.() || window.location.reload() |
|||
|
|||
this.close() |
|||
} catch (error) { |
|||
this.logger.error('Error while creating the new file from template', error) |
|||
showError(this.t('files', 'Unable to create new file from template')) |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Return the current directory, fallback to root |
|||
* @returns {string} |
|||
*/ |
|||
getCurrentDirectory() { |
|||
const currentDirInfo = OCA?.Files?.App?.currentFileList?.dirInfo |
|||
|| { path: '/', name: '' } |
|||
|
|||
// Make sure we don't have double slashes |
|||
return `${currentDirInfo.path}/${currentDirInfo.name}`.replace(/\/\//gi, '/') |
|||
}, |
|||
}, |
|||
} |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
.templates-picker { |
|||
&__form { |
|||
padding: calc(var(--margin) * 2); |
|||
// Will be handled by the buttons |
|||
padding-bottom: 0; |
|||
} |
|||
|
|||
&__list { |
|||
display: grid; |
|||
grid-gap: calc(var(--margin) * 2); |
|||
grid-auto-columns: 1fr; |
|||
// We want maximum 5 columns. Putting 6 as we don't count the grid gap. So it will always be lower than 6 |
|||
max-width: calc(var(--fullwidth) * 6); |
|||
grid-template-columns: repeat(auto-fit, minmax(var(--fullwidth), 1fr)); |
|||
// Make sure all rows are the same height |
|||
grid-auto-rows: 1fr; |
|||
} |
|||
&__buttons { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
padding: calc(var(--margin) * 2) var(--margin); |
|||
position: sticky; |
|||
// Make sure the templates list doesn't weirdly peak under when scrolled. Happens on some rare occasions |
|||
bottom: -1px; |
|||
background-color: var(--color-main-background); |
|||
} |
|||
|
|||
// Make sure we're relative for the loading emptycontent on top |
|||
/deep/ .modal-container { |
|||
position: relative; |
|||
overflow-y: auto !important; |
|||
} |
|||
|
|||
&__loading { |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
justify-content: center; |
|||
width: 100%; |
|||
height: 100%; |
|||
margin: 0; |
|||
background-color: var(--color-main-background-translucent); |
|||
} |
|||
} |
|||
|
|||
</style> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue