forked from platypush/platypush
[#333] Enhanced file browser component.
- Added support for file/directory add/copy/move/rename/remove operations. - Added automatic detection of MIME types. - Added support for file view/download. - Added file uploader component. - Added custom sorting and other visualization options. - Added custom `Home` component to show configurable bookmarks above the filesystem root level. - Added file editor with automatic syntax highlight.
This commit is contained in:
parent
e672a7fb5c
commit
0657c80a5c
9 changed files with 2382 additions and 49 deletions
|
@ -3,6 +3,17 @@
|
|||
<Loading v-if="loading" />
|
||||
|
||||
<div class="nav" ref="nav">
|
||||
<div class="path-container">
|
||||
<span class="path" v-if="hasHomepage">
|
||||
<span class="token" @click="path = null">
|
||||
<i class="fa fa-home" />
|
||||
</span>
|
||||
|
||||
<span class="separator" v-if="pathTokens.length">
|
||||
<i class="fa fa-chevron-right" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="path"
|
||||
v-for="(token, i) in pathTokens"
|
||||
:key="i"
|
||||
|
@ -11,13 +22,32 @@
|
|||
{{ token }}
|
||||
</span>
|
||||
|
||||
<span class="separator" v-if="(i > 0 || pathTokens.length > 1) && i < pathTokens.length - 1">
|
||||
<span class="separator"
|
||||
v-if="(i > 0 || pathTokens.length > 1) && i < pathTokens.length - 1">
|
||||
<i class="fa fa-chevron-right" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="items" ref="items">
|
||||
<div class="btn-container">
|
||||
<Dropdown :style="{'min-width': '11em'}">
|
||||
<DropdownItem icon-class="fa fa-plus" text="New Folder" @input="showCreateDirectory = true" />
|
||||
<DropdownItem icon-class="fa fa-file" text="Create File" @input="showCreateFile = true" />
|
||||
<DropdownItem icon-class="fa fa-upload" text="Upload" @input="showUpload = true" />
|
||||
<DropdownItem icon-class="fa fa-sync" text="Refresh" @input="refresh" />
|
||||
<DropdownItem icon-class="fa fa-cog" text="Options" @input="showOptions = true" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Home :items="homepage"
|
||||
:filter="filter"
|
||||
:has-back="hasBack"
|
||||
@back="onBack"
|
||||
@input="onItemSelect"
|
||||
v-if="!path && hasHomepage" />
|
||||
|
||||
<div class="items" ref="items" v-else>
|
||||
<div class="row item"
|
||||
@click="onBack"
|
||||
v-if="(path?.length && path !== '/') || hasBack">
|
||||
|
@ -27,38 +57,182 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row item"
|
||||
ref="selectCurrent"
|
||||
@click="onSelectCurrentDirectory"
|
||||
v-if="hasSelectCurrentDirectory">
|
||||
<div class="col-10 left side">
|
||||
<i class="icon fa fa-hand-point-right" />
|
||||
<span class="name"><Select This Directory></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row item" v-for="(file, i) in filteredFiles" :key="i" @click="onItemSelect(file)">
|
||||
<div class="col-10">
|
||||
<i class="icon fa" :class="{'fa-file': file.type !== 'directory', 'fa-folder': file.type === 'directory'}" />
|
||||
<i class="icon fa" :class="fileIcons[file.path]" />
|
||||
<span class="name">
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-2 actions" v-if="fileActions.length">
|
||||
<Dropdown>
|
||||
<DropdownItem icon-class="fa fa-play" text="Play"
|
||||
@input="$emit('play', {type: 'file', url: `file://${file.path}`})"
|
||||
v-if="hasPlay && file.type !== 'directory'" />
|
||||
<div class="col-2 actions" v-if="Object.keys(fileActions[file.path] || {})?.length">
|
||||
<Dropdown :style="{'min-width': '11em'}">
|
||||
<DropdownItem
|
||||
v-for="(action, key) in fileActions[file.path]"
|
||||
:key="key"
|
||||
:icon-class="action.iconClass"
|
||||
:text="action.text"
|
||||
@input="action.onClick(file)"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal title="Options"
|
||||
:visible="showOptions"
|
||||
@close="showOptions = false">
|
||||
<div class="modal-body">
|
||||
<BrowserOptions :value="opts" @input="onOptsChange" />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="upload-file-container" v-if="showUpload">
|
||||
<FileUploader :path="path"
|
||||
:visible="showUpload"
|
||||
ref="uploader"
|
||||
@complete="onUploadCompleted"
|
||||
@close="showUpload = false" />
|
||||
</div>
|
||||
|
||||
<div class="info-modal-container" v-if="showInfoFile != null">
|
||||
<Modal title="File Info"
|
||||
:visible="showInfoFile != null"
|
||||
@close="showInfoFile = null">
|
||||
<div class="modal-body">
|
||||
<FileInfo :file="showInfoFile" :loading="loading" />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog :visible="editWarnings.length > 0"
|
||||
@close="clearEditFile"
|
||||
@input="editFile(editedFile, {force: true})">
|
||||
The following warnings were raised:
|
||||
|
||||
<ul>
|
||||
<li v-for="(warning, i) in editWarnings" :key="i">
|
||||
{{ warning }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
Are you sure you that you want to edit the file?
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog :visible="fileToRemove != null"
|
||||
@close="fileToRemove = null"
|
||||
@input="deleteFile(fileToRemove)">
|
||||
Are you sure you that you want to delete this file?<br/><br/>
|
||||
<b>{{ fileToRemove }}</b>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog :visible="directoryToRemove != null"
|
||||
@close="directoryToRemove = null"
|
||||
@input="deleteDirectory(directoryToRemove)">
|
||||
Are you sure you that you want to delete this directory?<br/><br/>
|
||||
<b>{{ directoryToRemove }}</b>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog :visible="directoryToRemove != null && directoryNotEmpty"
|
||||
@close="directoryToRemove = null; directoryNotEmpty = false"
|
||||
@input="deleteDirectory(directoryToRemove, {recursive: true})">
|
||||
This directory is not empty. Are you sure you that you want to delete it?<br/><br/>
|
||||
<b>{{ directoryToRemove }}</b>
|
||||
</ConfirmDialog>
|
||||
|
||||
<FileEditor :file="editedFile"
|
||||
:is-new="isNewFileEdit"
|
||||
:visible="editedFile != null"
|
||||
:uppercase="false"
|
||||
@close="clearEditFile"
|
||||
@save="refresh"
|
||||
v-if="editedFile && !editWarnings?.length" />
|
||||
|
||||
<TextPrompt :visible="showCreateDirectory"
|
||||
@input="createDirectory($event)"
|
||||
@close="showCreateDirectory = false" >
|
||||
Enter the name of the new directory:
|
||||
</TextPrompt>
|
||||
|
||||
<TextPrompt :visible="showCreateFile"
|
||||
@input="editNewFile"
|
||||
@close="showCreateFile = false" >
|
||||
Enter the name of the new file:
|
||||
</TextPrompt>
|
||||
|
||||
<TextPrompt :visible="fileToRename != null"
|
||||
:value="displayedFileToRename"
|
||||
@input="renameFile"
|
||||
@close="fileToRename = null" >
|
||||
Enter a new name for this file:<br/><br/>
|
||||
<b>{{ fileToRename }}</b>
|
||||
</TextPrompt>
|
||||
|
||||
<div class="copy-modal-container">
|
||||
<Modal :title="(copyFile != null ? 'Copy' : 'Move') + ' File'"
|
||||
:visible="showCopyModal"
|
||||
@close="showCopyModal = false"
|
||||
v-if="showCopyModal">
|
||||
<div class="modal-body">
|
||||
<Browser :path="path"
|
||||
:has-back="true"
|
||||
:has-select-current-directory="true"
|
||||
:show-directories="true"
|
||||
:show-files="false"
|
||||
@back="copyFile = null; moveFile = null"
|
||||
@input="copyOrMove" />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Loading from "@/components/Loading";
|
||||
import Utils from "@/Utils";
|
||||
import MediaUtils from "@/components/Media/Utils";
|
||||
import BrowserOptions from "./Browser/Options";
|
||||
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
||||
import Dropdown from "@/components/elements/Dropdown";
|
||||
import DropdownItem from "@/components/elements/DropdownItem";
|
||||
import FileEditor from "./EditorModal";
|
||||
import FileInfo from "./Info";
|
||||
import FileUploader from "./UploaderModal";
|
||||
import Home from "./Home";
|
||||
import Loading from "@/components/Loading";
|
||||
import MediaUtils from "@/components/Media/Utils";
|
||||
import Modal from "@/components/Modal";
|
||||
import TextPrompt from "@/components/elements/TextPrompt"
|
||||
import Utils from "@/Utils";
|
||||
|
||||
export default {
|
||||
name: "Browser",
|
||||
components: {DropdownItem, Dropdown, Loading},
|
||||
emits: [
|
||||
'back',
|
||||
'input',
|
||||
'path-change',
|
||||
'play',
|
||||
],
|
||||
mixins: [Utils, MediaUtils],
|
||||
emits: ['back', 'path-change', 'play', 'input'],
|
||||
components: {
|
||||
BrowserOptions,
|
||||
ConfirmDialog,
|
||||
DropdownItem,
|
||||
Dropdown,
|
||||
FileEditor,
|
||||
FileInfo,
|
||||
FileUploader,
|
||||
Home,
|
||||
Loading,
|
||||
Modal,
|
||||
TextPrompt,
|
||||
},
|
||||
|
||||
props: {
|
||||
hasBack: {
|
||||
|
@ -66,6 +240,11 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
|
||||
hasSelectCurrentDirectory: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
initialPath: {
|
||||
type: String,
|
||||
},
|
||||
|
@ -78,66 +257,440 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
filterTypes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
homepage: {
|
||||
type: Object,
|
||||
},
|
||||
|
||||
showDirectories: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
showFiles: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
path: this.initialPath,
|
||||
copyFile: null,
|
||||
directoryNotEmpty: false,
|
||||
directoryToRemove: null,
|
||||
editedFile: null,
|
||||
editWarnings: [],
|
||||
files: [],
|
||||
fileToRemove: null,
|
||||
fileToRename: null,
|
||||
info: {},
|
||||
isNewFileEdit: false,
|
||||
loading: false,
|
||||
mimeTypes: {},
|
||||
moveFile: null,
|
||||
opts: {
|
||||
showHidden: false,
|
||||
sortBy: 'name',
|
||||
reverseSort: false,
|
||||
},
|
||||
path: this.initialPath,
|
||||
showCreateDirectory: false,
|
||||
showCreateFile: false,
|
||||
showInfoFile: null,
|
||||
showOptions: false,
|
||||
showUpload: false,
|
||||
uploading: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredFiles() {
|
||||
if (!this.filter?.length)
|
||||
return this.files
|
||||
|
||||
return this.files.filter((file) => (file?.name || '').toLowerCase().indexOf(this.filter.toLowerCase()) >= 0)
|
||||
displayedFileToRename() {
|
||||
return this.fileToRename?.slice(this.path.length + 1)
|
||||
},
|
||||
|
||||
hasPlay() {
|
||||
return this.isMedia && this.files.some((file) => this.mediaExtensions.has(file.name.split('.').pop()?.toLowerCase()))
|
||||
editedFileName() {
|
||||
return this.editedFile?.split('/').pop() || 'Untitled'
|
||||
},
|
||||
|
||||
filteredTypesMap() {
|
||||
return this.filterTypes.reduce((obj, type) => {
|
||||
obj[type] = true
|
||||
type.split('/').forEach((part) => {
|
||||
obj[part] = true
|
||||
})
|
||||
|
||||
return obj
|
||||
}, {})
|
||||
},
|
||||
|
||||
filteredFiles() {
|
||||
return this.files.filter(
|
||||
file => {
|
||||
if (file.type === 'directory' && !this.showDirectories)
|
||||
return false
|
||||
if (file.type !== 'directory' && !this.showFiles)
|
||||
return false
|
||||
if ((file?.name || '').toLowerCase().indexOf(this.filter.toLowerCase()) < 0)
|
||||
return false
|
||||
if (!this.opts.showHidden && file.name.startsWith('.'))
|
||||
return false
|
||||
|
||||
if (this.filterTypes.length) {
|
||||
const mime = this.mimeTypes[file.path] || ''
|
||||
const tokens = [mime, ...mime.split('/')]
|
||||
|
||||
if (!tokens.some((token) => this.fileredTypesMap[token]))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
fileActions() {
|
||||
if (!this.hasPlay)
|
||||
return []
|
||||
return this.files.reduce((obj, file) => {
|
||||
const mime = this.mimeTypes[file.path] || ''
|
||||
obj[file.path] = {}
|
||||
|
||||
return [
|
||||
{
|
||||
if (mime.startsWith('audio/') || mime.startsWith('video/'))
|
||||
obj[file.path] = {
|
||||
play: {
|
||||
iconClass: 'fa fa-play',
|
||||
text: 'Play',
|
||||
onClick: (file) => this.$emit('play', {type: 'file', url: `file://${file.path}`}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (file.type !== 'directory') {
|
||||
obj[file.path].view = {
|
||||
iconClass: 'fa fa-eye',
|
||||
text: 'View',
|
||||
onClick: (file) => this.viewFile(file.path),
|
||||
}
|
||||
|
||||
obj[file.path].download = {
|
||||
iconClass: 'fa fa-download',
|
||||
text: 'Download',
|
||||
onClick: (file) => this.downloadFile(file.path),
|
||||
}
|
||||
|
||||
obj[file.path].edit = {
|
||||
iconClass: 'fa fa-edit',
|
||||
text: 'Edit',
|
||||
onClick: (file) => this.editFile(file.path),
|
||||
}
|
||||
|
||||
obj[file.path].copy = {
|
||||
iconClass: 'fa fa-copy',
|
||||
text: 'Copy',
|
||||
onClick: (file) => this.copyFile = file.path,
|
||||
}
|
||||
|
||||
obj[file.path].move = {
|
||||
iconClass: 'fa fa-arrows-alt',
|
||||
text: 'Move',
|
||||
onClick: (file) => this.moveFile = file.path,
|
||||
}
|
||||
|
||||
obj[file.path].rename = {
|
||||
iconClass: 'fa fa-pen',
|
||||
text: 'Rename',
|
||||
onClick: (file) => this.fileToRename = file.path,
|
||||
}
|
||||
|
||||
obj[file.path].info = {
|
||||
iconClass: 'fa fa-info',
|
||||
text: 'Info',
|
||||
onClick: (file) => this.showInfoFile = file.path,
|
||||
}
|
||||
|
||||
obj[file.path].delete = {
|
||||
iconClass: 'delete fa fa-trash',
|
||||
text: 'Delete',
|
||||
onClick: (file) => this.fileToRemove = file.path,
|
||||
}
|
||||
} else {
|
||||
obj[file.path].delete = {
|
||||
iconClass: 'delete fa fa-trash',
|
||||
text: 'Delete',
|
||||
onClick: (file) => this.directoryToRemove = file.path,
|
||||
}
|
||||
}
|
||||
|
||||
return obj
|
||||
}, {})
|
||||
},
|
||||
|
||||
fileIcons() {
|
||||
return this.files.reduce((obj, file) => {
|
||||
if (file.type === 'directory') {
|
||||
obj[file.path] = 'fa-folder'
|
||||
} else {
|
||||
const mime = this.mimeTypes[file.path] || ''
|
||||
switch (true) {
|
||||
case mime.startsWith('audio/'):
|
||||
obj[file.path] = 'fa-file-audio'
|
||||
break
|
||||
case mime.startsWith('video/'):
|
||||
obj[file.path] = 'fa-file-video'
|
||||
break
|
||||
case mime.startsWith('image/'):
|
||||
obj[file.path] = 'fa-file-image'
|
||||
break
|
||||
case mime.startsWith('text/'):
|
||||
obj[file.path] = 'fa-file-alt'
|
||||
break
|
||||
default:
|
||||
obj[file.path] = 'fa-file'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return obj
|
||||
}, {})
|
||||
},
|
||||
|
||||
hasHomepage() {
|
||||
return Object.keys(this.homepage || {}).length
|
||||
},
|
||||
|
||||
pathTokens() {
|
||||
if (!this.path)
|
||||
return []
|
||||
|
||||
if (!this.path?.length)
|
||||
return ['/']
|
||||
|
||||
return ['/', ...this.path.split(/(?<!\\)\//).slice(1)]
|
||||
return ['/', ...this.path.split(/(?<!\\)\//).slice(1)].filter((token) => token.length)
|
||||
},
|
||||
|
||||
showCopyModal() {
|
||||
return this.copyFile != null || this.moveFile != null
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
initOpts() {
|
||||
const args = this.getUrlArgs()
|
||||
if (args.showHidden != null)
|
||||
this.opts.showHidden = !!args.showHidden
|
||||
|
||||
if (args.sortBy != null)
|
||||
this.opts.sortBy = args.sortBy
|
||||
|
||||
if (args.reverseSort != null)
|
||||
this.opts.reverseSort = !!args.reverseSort
|
||||
|
||||
if (args.file != null)
|
||||
this.editedFile = args.file
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
this.$nextTick(() => {
|
||||
// Scroll to the end of the path navigator
|
||||
if (this.$refs.nav) {
|
||||
this.$refs.nav.scrollLeft = 99999
|
||||
}
|
||||
|
||||
// Scroll to the top of the items list
|
||||
if (this.$refs.items) {
|
||||
this.$refs.items.scrollTop = 0
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
this.files = await this.request('file.list', {path: this.path})
|
||||
this.files = await this.request(
|
||||
'file.list',
|
||||
{
|
||||
path: this.path,
|
||||
sort: this.opts.sortBy,
|
||||
reverse: this.opts.reverseSort,
|
||||
}
|
||||
)
|
||||
|
||||
this.$emit('path-change', this.path)
|
||||
this.setUrlArgs({path: decodeURIComponent(this.path)})
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
await this.refreshMimeTypes()
|
||||
},
|
||||
|
||||
async refreshMimeTypes() {
|
||||
this.mimeTypes = await this.request(
|
||||
'file.get_mime_types', {
|
||||
files: this.files
|
||||
.filter((file) => file.type !== 'directory')
|
||||
.map((file) => file.path)
|
||||
})
|
||||
},
|
||||
|
||||
viewFile(path) {
|
||||
window.open(`/file?path=${encodeURIComponent(path)}`, '_blank')
|
||||
},
|
||||
|
||||
async editNewFile(name) {
|
||||
return await this.editFile(`${this.path}/${name}`, {newFile: true})
|
||||
},
|
||||
|
||||
async editFile(path, opts) {
|
||||
const force = !!opts?.force
|
||||
const newFile = this.isNewFileEdit = !!opts?.newFile
|
||||
|
||||
if (force) {
|
||||
this.editWarnings = []
|
||||
} else {
|
||||
if (!newFile) {
|
||||
const [info, isBinary] = await Promise.all([
|
||||
this.request('file.info', {files: [path]}),
|
||||
this.request('file.is_binary', {file: path}),
|
||||
])
|
||||
|
||||
const size = info?.[path]?.size || 0
|
||||
|
||||
if (isBinary) {
|
||||
this.editWarnings.push('File is binary')
|
||||
}
|
||||
|
||||
if ((info[path]?.size || 0) > 1024 * 1024) {
|
||||
this.editWarnings.push(`File is too large (${this.convertSize(size)})`)
|
||||
}
|
||||
|
||||
if (this.editWarnings.length) {
|
||||
this.editedFile = path
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.editedFile = path
|
||||
},
|
||||
|
||||
async deleteFile() {
|
||||
if (!this.fileToRemove)
|
||||
return
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
await this.request('file.unlink', {file: this.fileToRemove})
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.fileToRemove = null
|
||||
}
|
||||
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
async deleteDirectory(directory, opts) {
|
||||
directory = directory || this.directoryToRemove
|
||||
if (!directory)
|
||||
return
|
||||
|
||||
const recursive = !!opts?.recursive
|
||||
let isNotEmpty = false
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.request('file.rmdir', {directory, recursive})
|
||||
} catch (error) {
|
||||
if (typeof error === 'string' && error.search(/^\[?Errno 39\]?/i) >= 0) {
|
||||
isNotEmpty = true
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.directoryNotEmpty = isNotEmpty
|
||||
if (!isNotEmpty) {
|
||||
this.directoryToRemove = null
|
||||
}
|
||||
}
|
||||
|
||||
if (isNotEmpty) {
|
||||
this.directoryToRemove = directory
|
||||
} else {
|
||||
this.refresh()
|
||||
}
|
||||
},
|
||||
|
||||
async createDirectory(name) {
|
||||
if (!name)
|
||||
return
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
await this.request('file.mkdir', {directory: `${this.path}/${name}`})
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
async copyOrMove(target) {
|
||||
let operation = null
|
||||
let file = null
|
||||
if (this.copyFile) {
|
||||
operation = 'copy'
|
||||
file = this.copyFile
|
||||
} else if (this.moveFile) {
|
||||
operation = 'move'
|
||||
file = this.moveFile
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
await this.request(`file.${operation}`, {source: file, target})
|
||||
this.notify({
|
||||
text: `File ${operation} completed successfully`,
|
||||
title: 'Success',
|
||||
image: {
|
||||
icon: 'check',
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.copyFile = null
|
||||
this.moveFile = null
|
||||
}
|
||||
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
async renameFile(newName) {
|
||||
if (!this.fileToRename || !newName?.trim()?.length)
|
||||
return
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
await this.request('file.rename', {file: this.fileToRename, name: `${this.path}/${newName}`})
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.fileToRename = null
|
||||
}
|
||||
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
clearEditFile() {
|
||||
this.editedFile = null
|
||||
this.editWarnings = []
|
||||
},
|
||||
|
||||
downloadFile(path) {
|
||||
window.open(`/file?path=${encodeURIComponent(path)}&download=true`, '_blank')
|
||||
},
|
||||
|
||||
onOptsChange(opts) {
|
||||
this.opts = opts
|
||||
},
|
||||
|
||||
onBack() {
|
||||
|
@ -153,6 +706,14 @@ export default {
|
|||
else
|
||||
this.$emit('input', file.path)
|
||||
},
|
||||
|
||||
onSelectCurrentDirectory() {
|
||||
this.$emit('input', this.path)
|
||||
},
|
||||
|
||||
onUploadCompleted() {
|
||||
this.refresh()
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
@ -160,28 +721,50 @@ export default {
|
|||
this.path = this.initialPath
|
||||
},
|
||||
|
||||
path() {
|
||||
opts: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.setUrlArgs(this.opts)
|
||||
this.refresh()
|
||||
},
|
||||
},
|
||||
|
||||
path(val, oldVal) {
|
||||
if (oldVal === val || !oldVal)
|
||||
return
|
||||
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
showUpload(val) {
|
||||
const uploader = this.$refs.uploader
|
||||
if (val) {
|
||||
uploader?.open()
|
||||
this.$nextTick(() => {
|
||||
uploader?.focus()
|
||||
})
|
||||
} else {
|
||||
uploader?.close()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const args = this.getUrlArgs()
|
||||
if (args.path)
|
||||
this.path = args.path
|
||||
|
||||
this.initOpts()
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
this.setUrlArgs({path: null})
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "src/style/items";
|
||||
|
||||
$btn-container-width: 1.5em;
|
||||
|
||||
.browser {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
@ -198,5 +781,102 @@ export default {
|
|||
height: calc(100% - #{$nav-height});
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 !important;
|
||||
|
||||
.btn-container {
|
||||
width: $btn-container-width;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $default-hover-fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.path-container {
|
||||
flex-grow: 1;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
overflow: auto;
|
||||
padding: 0.5em 1em;
|
||||
|
||||
.path {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.modal) {
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
min-width: 20em;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 1em 0 0.5em 2em;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
list-style: disc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-file-container {
|
||||
:deep(.modal) {
|
||||
.modal-body {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copy-modal-container {
|
||||
:deep(.modal) {
|
||||
width: 100%;
|
||||
|
||||
.content {
|
||||
width: 80%;
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
.body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<div class="browser-options">
|
||||
<Loading v-if="loading" />
|
||||
|
||||
<div class="options-body" v-else>
|
||||
<div class="row item">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
:checked="value.showHidden"
|
||||
:value="value.showHidden"
|
||||
@input="e => $emit('input', { ...value, showHidden: e.target.checked })">
|
||||
Show hidden files
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row item sort-container">
|
||||
<span>
|
||||
<label>
|
||||
Sort by
|
||||
|
||||
<span>
|
||||
<select :value="value.sortBy" @input="e => $emit('input', { ...value, sortBy: e.target.value })">
|
||||
<option value="name" :selected="value.sortBy === 'name'">Name</option>
|
||||
<option value="size" :selected="value.sortBy === 'size'">Size</option>
|
||||
<option value="created" :selected="value.sortBy === 'created'">Creation Date</option>
|
||||
<option value="last_modified" :selected="value.sortBy === 'last_modified'">Last Modified</option>
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<label>
|
||||
<input type="radio"
|
||||
:checked="!value.reverseSort"
|
||||
@input="e => $emit('input', { ...value, reverseSort: false })">
|
||||
Ascending
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio"
|
||||
:checked="value.reverseSort"
|
||||
@input="e => $emit('input', { ...value, reverseSort: true })">
|
||||
Descending
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Loading from "@/components/Loading";
|
||||
import Utils from "@/Utils";
|
||||
|
||||
export default {
|
||||
emits: ['input'],
|
||||
mixins: [Utils],
|
||||
components: {
|
||||
Loading,
|
||||
},
|
||||
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "src/style/items";
|
||||
|
||||
.browser-options {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.options-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 1em;
|
||||
label {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-container {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
387
platypush/backend/http/webapp/src/components/File/Editor.vue
Normal file
387
platypush/backend/http/webapp/src/components/File/Editor.vue
Normal file
|
@ -0,0 +1,387 @@
|
|||
<template>
|
||||
<div class="file-editor">
|
||||
<Loading v-if="loading" />
|
||||
|
||||
<div class="editor-container">
|
||||
<div class="editor-highlight-loading" v-if="isProcessing">
|
||||
<Loading />
|
||||
</div>
|
||||
|
||||
<div class="editor-body">
|
||||
<div class="line-numbers" ref="lineNumbers">
|
||||
<span class="line-number" v-for="n in lines" :key="n" v-text="n" />
|
||||
</div>
|
||||
|
||||
<pre ref="pre"><code ref="content" v-html="displayedContent" /></pre>
|
||||
<textarea ref="textarea" v-model="content" @scroll="syncScroll" @input.stop />
|
||||
</div>
|
||||
|
||||
<FloatingButton icon-class="fa fa-save"
|
||||
title="Save"
|
||||
:disabled="!hasChanges || saving"
|
||||
@click="saveFile"
|
||||
v-if="withSave" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github.css';
|
||||
|
||||
import FloatingButton from "@/components/elements/FloatingButton";
|
||||
import Highlighter from "./Highlighter";
|
||||
import Loading from "@/components/Loading";
|
||||
import Utils from "@/Utils";
|
||||
|
||||
export default {
|
||||
mixins: [Highlighter, Utils],
|
||||
emits: ['save'],
|
||||
components: {
|
||||
FloatingButton,
|
||||
Loading,
|
||||
},
|
||||
|
||||
props: {
|
||||
file: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
isNew: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
withSave: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
content: '',
|
||||
currentContentHash: 0,
|
||||
highlightedContent: '',
|
||||
highlighting: false,
|
||||
highlightTimer: null,
|
||||
info: {},
|
||||
initialContentHash: 0,
|
||||
loading: false,
|
||||
saving: false,
|
||||
type: null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
codeClass() {
|
||||
return this.type?.length ? `language-${this.type}` : 'language-plaintext'
|
||||
},
|
||||
|
||||
displayedContent() {
|
||||
return this.highlightedContent?.length ? this.highlightedContent : this.content
|
||||
},
|
||||
|
||||
hasChanges() {
|
||||
return this.initialContentHash !== this.currentContentHash
|
||||
},
|
||||
|
||||
isProcessing() {
|
||||
return this.highlighting || this.highlightTimer || this.saving
|
||||
},
|
||||
|
||||
lines() {
|
||||
if (!this.content?.length) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return this.content.split('\n').length
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadFile() {
|
||||
this.setUrlArgs({file: this.file})
|
||||
if (this.isNew) {
|
||||
this.content = ''
|
||||
this.initialContentHash = 0
|
||||
this.highlightedContent = ''
|
||||
this.info = {}
|
||||
this.type = this.getLanguageType({path: this.file})
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
this.info = (
|
||||
await this.request('file.info', {files: [this.file]})
|
||||
)[this.file] || {}
|
||||
|
||||
this.type = this.getLanguageType(this.info)
|
||||
this.content = (
|
||||
await axios.get(`/file?path=${encodeURIComponent(this.file)}`)
|
||||
).data
|
||||
|
||||
if (typeof this.content === 'object') {
|
||||
this.content = JSON.stringify(this.content, null, 2)
|
||||
}
|
||||
|
||||
this.initialContentHash = this.content.hashCode()
|
||||
} catch (e) {
|
||||
this.notify({
|
||||
error: true,
|
||||
text: e.message,
|
||||
title: 'Failed to load file',
|
||||
})
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async saveFile() {
|
||||
if (!this.hasChanges) {
|
||||
return
|
||||
}
|
||||
|
||||
this.saving = true
|
||||
|
||||
try {
|
||||
await axios.put(`/file?path=${encodeURIComponent(this.file)}`, this.content)
|
||||
this.initialContentHash = this.content.hashCode()
|
||||
this.notify({
|
||||
title: 'File saved',
|
||||
text: `${this.file} saved`,
|
||||
image: {
|
||||
icon: 'check',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
this.notify({
|
||||
error: true,
|
||||
text: e.message,
|
||||
title: 'Failed to save file',
|
||||
})
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
|
||||
this.$emit('save')
|
||||
},
|
||||
|
||||
syncScroll(e) {
|
||||
const [scrollTop, scrollLeft] = [e.target.scrollTop, e.target.scrollLeft]
|
||||
const scrollHeight = Math.min(e.target.scrollHeight, this.$refs.pre.scrollHeight)
|
||||
const clientHeight = Math.min(e.target.clientHeight, this.$refs.pre.clientHeight)
|
||||
const maxScrollTop = scrollHeight - clientHeight
|
||||
const scrollOpts = {
|
||||
top: Math.min(scrollTop, maxScrollTop),
|
||||
left: scrollLeft,
|
||||
behavior: 'auto',
|
||||
}
|
||||
|
||||
e.target.scrollTo(scrollOpts)
|
||||
this.$refs.pre.scrollTo(scrollOpts)
|
||||
this.$refs.lineNumbers.scrollTo({
|
||||
top: scrollOpts.top,
|
||||
behavior: 'auto',
|
||||
})
|
||||
},
|
||||
|
||||
highlightContent() {
|
||||
this.highlighting = true
|
||||
|
||||
try {
|
||||
clearTimeout(this.highlightTimer)
|
||||
this.highlightTimer = null
|
||||
this.highlightedContent = hljs.highlight(this.content, {language: this.type || 'plaintext'}).value
|
||||
} finally {
|
||||
this.highlighting = false
|
||||
}
|
||||
},
|
||||
|
||||
async keyListener(event) {
|
||||
if (event.key === 's' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault()
|
||||
await this.saveFile()
|
||||
}
|
||||
},
|
||||
|
||||
addKeyListener() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
this.saveFile()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
removeKeyListener() {
|
||||
window.removeEventListener('keydown', (e) => {
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
this.saveFile()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
beforeUnload(e) {
|
||||
if (this.hasChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
},
|
||||
|
||||
addBeforeUnload() {
|
||||
window.addEventListener('beforeunload', this.beforeUnload)
|
||||
},
|
||||
|
||||
removeBeforeUnload() {
|
||||
window.removeEventListener('beforeunload', this.beforeUnload)
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.setUrlArgs({file: null})
|
||||
this.removeBeforeUnload()
|
||||
this.removeKeyListener()
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
file() {
|
||||
this.loadFile()
|
||||
},
|
||||
|
||||
content() {
|
||||
if (!this.content?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
this.currentContentHash = this.content.hashCode()
|
||||
|
||||
if (!this.highlightedContent?.length) {
|
||||
this.highlightContent()
|
||||
} else {
|
||||
if (this.highlightTimer) {
|
||||
clearTimeout(this.highlightTimer)
|
||||
}
|
||||
|
||||
this.highlightTimer = setTimeout(this.highlightContent, 1000)
|
||||
|
||||
// Temporarily disable highlighting until the user stops typing,
|
||||
// so we don't highlight on every keystroke and we don't lose alignment
|
||||
// between the textarea and the pre element.
|
||||
this.highlightedContent = this.content
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadFile()
|
||||
this.addBeforeUnload()
|
||||
this.addKeyListener()
|
||||
this.$nextTick(() => {
|
||||
this.$refs.textarea.focus()
|
||||
})
|
||||
},
|
||||
|
||||
unmouted() {
|
||||
this.reset()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "src/style/items";
|
||||
|
||||
$line-numbers-width: 2.5em;
|
||||
|
||||
.file-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.editor-container,
|
||||
.editor-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-highlight-loading {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 1em;
|
||||
width: 10em;
|
||||
height: 2em;
|
||||
font-size: 0.5em !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0.5;
|
||||
z-index: 2;
|
||||
|
||||
:deep(.loading) {
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
pre, textarea, code, .line-numbers {
|
||||
font-family: 'Fira Code', 'Noto Sans Mono', 'Inconsolata', 'Courier New', monospace;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
width: $line-numbers-width;
|
||||
background: $tab-bg;
|
||||
border-right: $default-border;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-end;
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
|
||||
.line-number {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
padding-right: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
pre, textarea {
|
||||
width: calc(100% - #{$line-numbers-width} - 1em);
|
||||
left: calc(#{$line-numbers-width} + 1em);
|
||||
}
|
||||
|
||||
code {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: transparent;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: scroll;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0);
|
||||
caret-color: black;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.floating-btn) {
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,179 @@
|
|||
<template>
|
||||
<div class="file-editor-root">
|
||||
<div class="file-editor-modal" :class="{ maximized }">
|
||||
<Modal v-bind="proxiedProperties"
|
||||
ref="modal"
|
||||
@close="onClose">
|
||||
<div class="modal-body">
|
||||
<FileEditor ref="fileEditor"
|
||||
:file="file"
|
||||
:is-new="isNew"
|
||||
@save="$emit('save', $event)"
|
||||
v-if="file" />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="confirm-dialog-container">
|
||||
<ConfirmDialog ref="confirmClose" @input="forceClose">
|
||||
This file has unsaved changes. Are you sure you want to close it?
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
||||
import FileEditor from "./Editor";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
export default {
|
||||
emits: ['close', 'open', 'save'],
|
||||
mixins: [Modal],
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
FileEditor,
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
file: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
isNew: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
withSave: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
confirmClose: true,
|
||||
maximized: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filename() {
|
||||
return this.file.split('/').pop() || 'Untitled'
|
||||
},
|
||||
|
||||
headerButtons() {
|
||||
const buttons = []
|
||||
|
||||
if (this.maximized) {
|
||||
buttons.push({
|
||||
title: 'Restore',
|
||||
icon: 'far fa-window-restore',
|
||||
action: () => this.maximized = false,
|
||||
})
|
||||
} else {
|
||||
buttons.push({
|
||||
title: 'Maximize',
|
||||
icon: 'far fa-window-maximize',
|
||||
action: () => this.maximized = true,
|
||||
})
|
||||
}
|
||||
|
||||
return buttons
|
||||
},
|
||||
|
||||
proxiedProperties() {
|
||||
const props = {...this.$props}
|
||||
delete props.file
|
||||
delete props.withSave
|
||||
props.buttons = this.headerButtons
|
||||
props.title = this.filename
|
||||
props.beforeClose = this.checkClose
|
||||
return props
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
checkClose() {
|
||||
if (this.withSave && this.confirmClose && this.$refs.fileEditor.hasChanges) {
|
||||
this.$refs.confirmClose.open()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
forceClose() {
|
||||
this.confirmClose = false
|
||||
this.$refs.modal.close()
|
||||
},
|
||||
|
||||
onClose() {
|
||||
this.$refs.fileEditor.reset()
|
||||
this.$emit('close')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "src/style/items";
|
||||
|
||||
.file-editor-root {
|
||||
.file-editor-modal {
|
||||
:deep(.modal) {
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
width: 100%;
|
||||
height: 50em;
|
||||
min-width: 30em;
|
||||
max-height: calc(100vh - 2em);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.maximized) {
|
||||
:deep(.body) {
|
||||
@include until($tablet) {
|
||||
width: calc(100vw - 2em);
|
||||
}
|
||||
|
||||
@include from($tablet) {
|
||||
width: 40em;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@include from($desktop) {
|
||||
width: 100%;
|
||||
min-width: 50em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.maximized {
|
||||
:deep(.body) {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-dialog-container {
|
||||
:deep(.modal) {
|
||||
.content {
|
||||
.body {
|
||||
min-width: 30em;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,389 @@
|
|||
<script>
|
||||
const languageMappers = {
|
||||
actionscript: {
|
||||
extensions: ['.as'],
|
||||
types: ['text/x-actionscript'],
|
||||
},
|
||||
ada: {
|
||||
extensions: ['.ada', '.adb', '.ads'],
|
||||
types: ['text/x-ada'],
|
||||
},
|
||||
apache: {
|
||||
extensions: ['.htaccess', '.htpasswd'],
|
||||
types: ['text/x-apache'],
|
||||
},
|
||||
arduino: {
|
||||
extensions: ['.ino'],
|
||||
types: ['text/x-arduino'],
|
||||
},
|
||||
autoit: {
|
||||
extensions: ['.au3'],
|
||||
types: ['text/x-autoit'],
|
||||
},
|
||||
awk: {
|
||||
extensions: ['.awk'],
|
||||
types: ['text/x-awk'],
|
||||
},
|
||||
bash: {
|
||||
extensions: ['.sh', '.bash'],
|
||||
types: ['text/x-sh'],
|
||||
},
|
||||
basic: {
|
||||
extensions: ['.bas', '.basic'],
|
||||
types: ['text/x-basic'],
|
||||
},
|
||||
bnf: {
|
||||
extensions: ['.bnf'],
|
||||
types: ['text/x-bnf'],
|
||||
},
|
||||
c: {
|
||||
extensions: ['.c', '.h'],
|
||||
types: ['text/x-c'],
|
||||
},
|
||||
clojure: {
|
||||
extensions: ['.clj', '.cljc', '.cljx', '.cljs', '.edn'],
|
||||
types: ['text/x-clojure'],
|
||||
},
|
||||
cmake: {
|
||||
extensions: ['.cmake', '.cmake.in'],
|
||||
types: ['text/x-cmake'],
|
||||
},
|
||||
coffeescript: {
|
||||
extensions: ['.coffee'],
|
||||
types: ['text/x-coffeescript'],
|
||||
},
|
||||
cpp: {
|
||||
extensions: ['.cpp', '.cc', '.cxx', '.c++', '.h', '.hh', '.hpp', '.hxx', '.h++'],
|
||||
types: ['text/x-c++src'],
|
||||
},
|
||||
crystal: {
|
||||
extensions: ['.cr'],
|
||||
types: ['text/x-crystal'],
|
||||
},
|
||||
css: {
|
||||
extensions: ['.css'],
|
||||
types: ['text/css'],
|
||||
},
|
||||
d: {
|
||||
extensions: ['.d'],
|
||||
types: ['text/x-d'],
|
||||
},
|
||||
dart: {
|
||||
extensions: ['.dart'],
|
||||
types: ['text/x-dart'],
|
||||
},
|
||||
delphi: {
|
||||
extensions: ['.pas', '.dpr', '.dfm', '.dpk', '.dproj'],
|
||||
types: ['text/x-pascal'],
|
||||
},
|
||||
diff: {
|
||||
extensions: ['.diff', '.patch'],
|
||||
types: ['text/x-diff'],
|
||||
},
|
||||
dns: {
|
||||
extensions: ['.zone', '.arpa'],
|
||||
types: ['text/x-dns'],
|
||||
},
|
||||
dockerfile: {
|
||||
extensions: ['Dockerfile'],
|
||||
types: ['text/x-dockerfile'],
|
||||
},
|
||||
dos: {
|
||||
extensions: ['.bat', '.cmd'],
|
||||
types: ['text/x-dos'],
|
||||
},
|
||||
dsconfig: {
|
||||
extensions: ['.dsconfig'],
|
||||
types: ['text/x-dsconfig'],
|
||||
},
|
||||
dts: {
|
||||
extensions: ['.dts', '.dtsi'],
|
||||
types: ['text/x-dts'],
|
||||
},
|
||||
dust: {
|
||||
extensions: ['.dust'],
|
||||
types: ['text/x-dust'],
|
||||
},
|
||||
ebnf: {
|
||||
extensions: ['.ebnf'],
|
||||
types: ['text/x-ebnf'],
|
||||
},
|
||||
elixir: {
|
||||
extensions: ['.ex', '.exs'],
|
||||
types: ['text/x-elixir'],
|
||||
},
|
||||
elm: {
|
||||
extensions: ['.elm'],
|
||||
types: ['text/x-elm'],
|
||||
},
|
||||
erlang: {
|
||||
extensions: ['.erl'],
|
||||
types: ['text/x-erlang'],
|
||||
},
|
||||
excel: {
|
||||
extensions: ['.xls', '.xlsx'],
|
||||
types: ['text/x-excel'],
|
||||
},
|
||||
fortran: {
|
||||
extensions: ['.f', '.f77', '.f90', '.f95'],
|
||||
types: ['text/x-fortran'],
|
||||
},
|
||||
fsharp: {
|
||||
extensions: ['.fs', '.fsi', '.fsx', '.fsscript'],
|
||||
types: ['text/x-fsharp'],
|
||||
},
|
||||
gherkin: {
|
||||
extensions: ['.feature'],
|
||||
types: ['text/x-feature'],
|
||||
},
|
||||
go: {
|
||||
extensions: ['.go'],
|
||||
types: ['text/x-go'],
|
||||
},
|
||||
gradle: {
|
||||
extensions: ['.gradle'],
|
||||
types: ['text/x-gradle'],
|
||||
},
|
||||
graphql: {
|
||||
extensions: ['.graphql'],
|
||||
types: ['text/x-graphql'],
|
||||
},
|
||||
groovy: {
|
||||
extensions: ['.groovy', '.gradle'],
|
||||
types: ['text/x-groovy'],
|
||||
},
|
||||
handlebars: {
|
||||
extensions: ['.hbs', '.handlebars'],
|
||||
types: ['text/x-handlebars-template'],
|
||||
},
|
||||
haskell: {
|
||||
extensions: ['.hs', '.lhs'],
|
||||
types: ['text/x-haskell'],
|
||||
},
|
||||
http: {
|
||||
extensions: ['.http'],
|
||||
types: ['message/http'],
|
||||
},
|
||||
ini: {
|
||||
extensions: ['.ini', '.toml'],
|
||||
types: ['text/x-ini'],
|
||||
},
|
||||
java: {
|
||||
extensions: ['.java'],
|
||||
types: ['text/x-java'],
|
||||
},
|
||||
html: {
|
||||
extensions: ['.html', '.htm'],
|
||||
types: ['text/html'],
|
||||
},
|
||||
javascript: {
|
||||
extensions: ['.js', '.mjs'],
|
||||
types: ['application/javascript'],
|
||||
},
|
||||
json: {
|
||||
extensions: ['.json'],
|
||||
types: ['application/json'],
|
||||
},
|
||||
julia: {
|
||||
extensions: ['.jl'],
|
||||
types: ['text/x-julia'],
|
||||
},
|
||||
kotlin: {
|
||||
extensions: ['.kt', '.kts'],
|
||||
types: ['text/x-kotlin'],
|
||||
},
|
||||
latex: {
|
||||
extensions: ['.tex'],
|
||||
types: ['text/x-latex'],
|
||||
},
|
||||
less: {
|
||||
extensions: ['.less'],
|
||||
types: ['text/x-less'],
|
||||
},
|
||||
lisp: {
|
||||
extensions: ['.lisp', '.lsp'],
|
||||
types: ['text/x-lisp'],
|
||||
},
|
||||
llvm: {
|
||||
extensions: ['.ll'],
|
||||
types: ['text/x-llvm'],
|
||||
},
|
||||
lua: {
|
||||
extensions: ['.lua'],
|
||||
types: ['text/x-lua'],
|
||||
},
|
||||
makefile: {
|
||||
extensions: ['Makefile'],
|
||||
types: ['text/x-makefile'],
|
||||
},
|
||||
markdown: {
|
||||
extensions: ['.md', '.markdown'],
|
||||
types: ['text/markdown'],
|
||||
},
|
||||
mathematica: {
|
||||
extensions: ['.m'],
|
||||
types: ['text/x-mathematica'],
|
||||
},
|
||||
matlab: {
|
||||
extensions: ['.m'],
|
||||
types: ['text/x-matlab'],
|
||||
},
|
||||
nginx: {
|
||||
extensions: ['.nginx', 'nginx.conf'],
|
||||
contains: ['/sites-available/', '/sites-enabled/'],
|
||||
types: ['text/x-nginx-conf'],
|
||||
},
|
||||
nim: {
|
||||
extensions: ['.nim', '.nimble'],
|
||||
types: ['text/x-nim'],
|
||||
},
|
||||
nix: {
|
||||
extensions: ['.nix'],
|
||||
types: ['text/x-nix'],
|
||||
},
|
||||
objectivec: {
|
||||
extensions: ['.m'],
|
||||
types: ['text/x-objectivec'],
|
||||
},
|
||||
ocaml: {
|
||||
extensions: ['.ml', '.mli'],
|
||||
types: ['text/x-ocaml'],
|
||||
},
|
||||
perl: {
|
||||
extensions: ['.pl', '.pm'],
|
||||
types: ['text/x-perl'],
|
||||
},
|
||||
pgsql: {
|
||||
extensions: ['.pgsql'],
|
||||
types: ['text/x-pgsql'],
|
||||
},
|
||||
php: {
|
||||
extensions: ['.php'],
|
||||
types: ['text/x-php'],
|
||||
},
|
||||
plaintext: {
|
||||
extensions: ['.txt']
|
||||
},
|
||||
powershell: {
|
||||
extensions: ['.ps1', '.psm1', '.psd1'],
|
||||
types: ['text/x-powershell'],
|
||||
},
|
||||
prolog: {
|
||||
extensions: ['.pro', '.prolog'],
|
||||
types: ['text/x-prolog'],
|
||||
},
|
||||
protobuf: {
|
||||
extensions: ['.proto'],
|
||||
types: ['text/x-protobuf'],
|
||||
},
|
||||
puppet: {
|
||||
extensions: ['.pp'],
|
||||
types: ['text/x-puppet'],
|
||||
},
|
||||
python: {
|
||||
extensions: ['.py'],
|
||||
types: ['text/x-python'],
|
||||
},
|
||||
r: {
|
||||
extensions: ['.r'],
|
||||
types: ['text/x-r'],
|
||||
},
|
||||
ruby: {
|
||||
extensions: ['.rb'],
|
||||
types: ['text/x-ruby'],
|
||||
},
|
||||
rust: {
|
||||
extensions: ['.rs'],
|
||||
types: ['text/x-rust'],
|
||||
},
|
||||
scala: {
|
||||
extensions: ['.scala'],
|
||||
types: ['text/x-scala'],
|
||||
},
|
||||
scheme: {
|
||||
extensions: ['.scm', '.ss'],
|
||||
types: ['text/x-scheme'],
|
||||
},
|
||||
scss: {
|
||||
extensions: ['.scss'],
|
||||
types: ['text/x-scss'],
|
||||
},
|
||||
smalltalk: {
|
||||
extensions: ['.st'],
|
||||
types: ['text/x-stsrc'],
|
||||
},
|
||||
sql: {
|
||||
extensions: ['.sql'],
|
||||
types: ['text/x-sql'],
|
||||
},
|
||||
swift: {
|
||||
extensions: ['.swift'],
|
||||
types: ['text/x-swift'],
|
||||
},
|
||||
tcl: {
|
||||
extensions: ['.tcl'],
|
||||
types: ['text/x-tcl'],
|
||||
},
|
||||
typescript: {
|
||||
extensions: ['.ts'],
|
||||
types: ['application/typescript'],
|
||||
},
|
||||
vbnet: {
|
||||
extensions: ['.vb'],
|
||||
types: ['text/x-vb'],
|
||||
},
|
||||
vbscript: {
|
||||
extensions: ['.vbs'],
|
||||
types: ['text/vbscript'],
|
||||
},
|
||||
vhdl: {
|
||||
extensions: ['.vhd', '.vhdl'],
|
||||
types: ['text/x-vhdl'],
|
||||
},
|
||||
vim: {
|
||||
extensions: ['.vim', '.vimrc'],
|
||||
types: ['text/x-vim'],
|
||||
},
|
||||
wasm: {
|
||||
extensions: ['.wasm'],
|
||||
types: ['application/wasm'],
|
||||
},
|
||||
x86asm: {
|
||||
extensions: ['.asm', '.s'],
|
||||
types: ['text/x-asm'],
|
||||
},
|
||||
xml: {
|
||||
extensions: ['.xml'],
|
||||
types: ['application/xml'],
|
||||
},
|
||||
yaml: {
|
||||
extensions: ['.yaml', '.yml'],
|
||||
types: ['text/x-yaml'],
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
getLanguageType(file) {
|
||||
for (const [language, mapper] of Object.entries(languageMappers)) {
|
||||
const matchingExtensions = mapper.extensions?.filter((ext) => file.path.toLowerCase().endsWith(ext))
|
||||
if (matchingExtensions?.length) {
|
||||
return language
|
||||
}
|
||||
|
||||
const matchingContains = mapper.contains?.filter((contains) => file.path.toLowerCase().includes(contains))
|
||||
if (matchingContains?.length) {
|
||||
return language
|
||||
}
|
||||
|
||||
const matchingTypes = mapper.types?.filter((type) => file.type === type)
|
||||
if (matchingTypes?.length) {
|
||||
return language
|
||||
}
|
||||
}
|
||||
|
||||
return 'plaintext'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
152
platypush/backend/http/webapp/src/components/File/Home.vue
Normal file
152
platypush/backend/http/webapp/src/components/File/Home.vue
Normal file
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<div class="browser-home">
|
||||
<div class="items" ref="items">
|
||||
<div class="row item" @click="$emit('back')" v-if="hasBack">
|
||||
<div class="icon-container">
|
||||
<i class="icon fa fa-chevron-left" />
|
||||
</div>
|
||||
<span class="name">Back</span>
|
||||
</div>
|
||||
|
||||
<div class="row item" v-for="(item, name) in filteredItems" :key="name" @click="$emit('input', item)">
|
||||
<div class="icon-container">
|
||||
<img class="icon" :src="item.icon.url" v-if="item.icon?.url?.length" />
|
||||
<i class="icon" :class="item.icon?.['class'] || 'fas fa-folder'" v-else />
|
||||
</div>
|
||||
|
||||
<span class="name">
|
||||
{{ name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from "@/Utils";
|
||||
|
||||
export default {
|
||||
mixins: [Utils],
|
||||
emits: ['back', 'input'],
|
||||
|
||||
props: {
|
||||
hasBack: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
filter: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
items: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
includeHome: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
includeRoot: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
userHome: null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
allItems() {
|
||||
return Object.entries(
|
||||
{
|
||||
...(this.includeRoot ? {
|
||||
Root: {
|
||||
name: 'Root',
|
||||
path: '/',
|
||||
icon: {
|
||||
'class': 'fas fa-hard-drive',
|
||||
}
|
||||
}
|
||||
} : {}),
|
||||
...(this.includeHome && this.userHome ? {
|
||||
Home: {
|
||||
name: 'Home',
|
||||
path: this.userHome,
|
||||
icon: {
|
||||
'class': 'fas fa-home',
|
||||
}
|
||||
}
|
||||
} : {}),
|
||||
...this.items,
|
||||
}
|
||||
).reduce((acc, [name, item]) => {
|
||||
if (!item.type?.length) {
|
||||
item.type = 'directory'
|
||||
}
|
||||
|
||||
acc[name] = {
|
||||
name,
|
||||
...item,
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
|
||||
filteredItems() {
|
||||
return Object.fromEntries(
|
||||
Object
|
||||
.entries(this.allItems)
|
||||
.filter(
|
||||
(entry) => entry[0].toLowerCase().includes(this.filter.toLowerCase())
|
||||
)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async getUserHome() {
|
||||
if (!this.userHome) {
|
||||
this.userHome = await this.request('file.get_user_home')
|
||||
}
|
||||
|
||||
return this.userHome
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getUserHome()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "src/style/items";
|
||||
|
||||
.browser-home {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.items {
|
||||
height: calc(100% - #{$nav-height});
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
width: 2em;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-width: calc(100% - 0.25em);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
126
platypush/backend/http/webapp/src/components/File/Info.vue
Normal file
126
platypush/backend/http/webapp/src/components/File/Info.vue
Normal file
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<div class="file-info">
|
||||
<Loading v-if="loading" />
|
||||
|
||||
<div class="file-info-container" v-else-if="info">
|
||||
<div class="row item">
|
||||
<div class="label">Path</div>
|
||||
<div class="value">{{ info.path }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row item" v-if="info.size != null">
|
||||
<div class="label">Size</div>
|
||||
<div class="value">{{ convertSize(info.size) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row item" v-if="info.created != null">
|
||||
<div class="label">Creation Date</div>
|
||||
<div class="value">{{ formatDate(info.created, true) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row item" v-if="info.last_modified != null">
|
||||
<div class="label">Last Modified</div>
|
||||
<div class="value">{{ formatDate(info.last_modified, true) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row item" v-if="info.mime_type != null">
|
||||
<div class="label">MIME type</div>
|
||||
<div class="value">{{ info.mime_type }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row item" v-if="info.permissions != null">
|
||||
<div class="label">Permissions</div>
|
||||
<div class="value">{{ info.permissions }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row item" v-if="info.owner != null">
|
||||
<div class="label">Owner ID</div>
|
||||
<div class="value">{{ info.owner }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row item" v-if="info.group != null">
|
||||
<div class="label">Group ID</div>
|
||||
<div class="value">{{ info.group }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Loading from "@/components/Loading";
|
||||
import MediaUtils from "@/components/Media/Utils";
|
||||
import Utils from "@/Utils";
|
||||
|
||||
export default {
|
||||
components: {Loading},
|
||||
mixins: [Utils, MediaUtils],
|
||||
|
||||
props: {
|
||||
file: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
info: {},
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
this.info = (
|
||||
await this.request(
|
||||
'file.info', {files: [this.file]}
|
||||
)
|
||||
)[this.file]
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
file() {
|
||||
this.refresh()
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.refresh()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "src/style/items";
|
||||
|
||||
.file-info {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.file-info-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
padding: 0.75em 0.5em;
|
||||
}
|
||||
|
||||
.label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
flex-grow: 1;
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
268
platypush/backend/http/webapp/src/components/File/Uploader.vue
Normal file
268
platypush/backend/http/webapp/src/components/File/Uploader.vue
Normal file
|
@ -0,0 +1,268 @@
|
|||
<template>
|
||||
<div class="upload-file-container">
|
||||
<Loading v-if="uploading" />
|
||||
<form ref="uploadForm" class="upload-form" @submit.prevent="uploadFiles()">
|
||||
<div class="row file-input">
|
||||
<input type="file"
|
||||
ref="files"
|
||||
multiple
|
||||
:disabled="uploading"
|
||||
@input="onFilesInput" />
|
||||
</div>
|
||||
|
||||
<div class="row btn-container">
|
||||
<button type="submit" :disabled="uploading || !hasFiles">
|
||||
<i class="fa fa-upload" /> Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="existing-files-container">
|
||||
<ConfirmDialog v-for="file in existingFiles"
|
||||
:key="file.name"
|
||||
:visible="true"
|
||||
@close="delete existingFiles[file.name]"
|
||||
@input="uploadFiles([file], {force: true})">
|
||||
The file <b>{{ file.name }}</b> already exists. Do you want to overwrite it?
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" v-if="Object.keys(progress || {}).length">
|
||||
<div class="row progress" v-for="(percent, file) in progress" :key="file">
|
||||
<span class="filename">{{ file }}</span>
|
||||
<span class="progress-bar-container">
|
||||
<progress class="progress-bar" :value="percent" max="100" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
||||
import Loading from "@/components/Loading";
|
||||
import Utils from "@/Utils";
|
||||
|
||||
export default {
|
||||
emits: ['complete', 'error', 'start'],
|
||||
mixins: [Utils],
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
Loading,
|
||||
},
|
||||
|
||||
props: {
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
existingFiles: {},
|
||||
hasFiles: false,
|
||||
progress: {},
|
||||
uploading: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
formFiles() {
|
||||
if (!this.$refs.files?.files) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.from(this.$refs.files.files)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async uploadFile(file, opts) {
|
||||
const { force } = opts || {}
|
||||
if (force) {
|
||||
delete this.existingFiles[file.name]
|
||||
}
|
||||
|
||||
try {
|
||||
const reqMethod = force ? 'put' : 'post'
|
||||
const response = await axios[reqMethod](
|
||||
`/file?path=${this.path}/${file.name}`,
|
||||
file,
|
||||
{
|
||||
onUploadProgress: (progressEvent) => {
|
||||
this.progress[file.name] = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
this.notify({
|
||||
title: 'File uploaded',
|
||||
text: `${file.name} uploaded to ${this.path}`,
|
||||
image: {
|
||||
icon: 'check',
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
file,
|
||||
status: response.status,
|
||||
}
|
||||
} catch (error) {
|
||||
const ret = {
|
||||
file,
|
||||
status: error.response?.status,
|
||||
error: error.response?.data?.error,
|
||||
}
|
||||
|
||||
if (ret.status !== 409) {
|
||||
this.onUploadError(error)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
},
|
||||
|
||||
async uploadFiles(files, opts) {
|
||||
const { force } = opts || {}
|
||||
files = files || this.formFiles
|
||||
files.forEach((file) => {
|
||||
delete this.existingFiles[file.name]
|
||||
})
|
||||
|
||||
if (!files?.length) {
|
||||
this.notify({
|
||||
title: 'No files selected',
|
||||
text: 'Please select files to upload',
|
||||
warning: true,
|
||||
image: {
|
||||
icon: 'upload',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.onUploadStarted(files)
|
||||
const failed = []
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
files.map((file) => this.uploadFile(file, { force }))
|
||||
)
|
||||
|
||||
failed.push(...responses.filter((r) => r?.error))
|
||||
if (!failed.length) {
|
||||
this.onUploadCompleted()
|
||||
}
|
||||
} finally {
|
||||
this.uploading = false
|
||||
}
|
||||
|
||||
const conflicts = failed.filter((r) => r?.status === 409 && r?.error)
|
||||
this.existingFiles = {
|
||||
...this.existingFiles,
|
||||
...conflicts.reduce((acc, r) => {
|
||||
acc[r.file.name] = r.file
|
||||
return acc
|
||||
}, {}),
|
||||
}
|
||||
},
|
||||
|
||||
onFilesInput(event) {
|
||||
this.hasFiles = Array.from(event.target.files).length > 0
|
||||
},
|
||||
|
||||
onUploadStarted(files) {
|
||||
this.uploading = true
|
||||
this.$emit('start')
|
||||
this.notify({
|
||||
title: 'Upload started',
|
||||
text: `Uploading ${files.length} file(s) to ${this.path}`,
|
||||
image: {
|
||||
icon: 'upload',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onUploadCompleted() {
|
||||
this.uploading = false
|
||||
this.$emit('complete')
|
||||
},
|
||||
|
||||
onUploadError(error) {
|
||||
const details = error.response?.data?.error
|
||||
if (details) {
|
||||
error.message = `${error.message}: ${details}`
|
||||
}
|
||||
|
||||
this.$emit('error', error)
|
||||
this.notify({
|
||||
title: 'Upload error',
|
||||
text: error.message,
|
||||
error: true,
|
||||
image: {
|
||||
icon: 'upload',
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-file-container {
|
||||
:deep(.modal) {
|
||||
.modal-body {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
.row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
|
||||
.filename {
|
||||
width: 35%;
|
||||
overflow: clip;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
progress[value] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 1.5em;
|
||||
border-radius: 0.75em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class="upload-file-modal-container">
|
||||
<Modal title="Upload File(s)" :visible="visible" @close="$emit('close')">
|
||||
<div class="modal-body">
|
||||
<FileUploader :path="path"
|
||||
@complete="$emit('complete')"
|
||||
@start="$emit('start')"
|
||||
@error="$emit('error')" />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FileUploader from "./Uploader";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
export default {
|
||||
mixins: [FileUploader, Modal],
|
||||
components: {
|
||||
FileUploader,
|
||||
Modal,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-file-modal-container {
|
||||
:deep(.modal) {
|
||||
.modal-body {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
Reference in a new issue