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" />
|
<Loading v-if="loading" />
|
||||||
|
|
||||||
<div class="nav" ref="nav">
|
<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"
|
<span class="path"
|
||||||
v-for="(token, i) in pathTokens"
|
v-for="(token, i) in pathTokens"
|
||||||
:key="i"
|
:key="i"
|
||||||
|
@ -11,13 +22,32 @@
|
||||||
{{ token }}
|
{{ token }}
|
||||||
</span>
|
</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" />
|
<i class="fa fa-chevron-right" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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"
|
<div class="row item"
|
||||||
@click="onBack"
|
@click="onBack"
|
||||||
v-if="(path?.length && path !== '/') || hasBack">
|
v-if="(path?.length && path !== '/') || hasBack">
|
||||||
|
@ -27,38 +57,182 @@
|
||||||
</div>
|
</div>
|
||||||
</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="row item" v-for="(file, i) in filteredFiles" :key="i" @click="onItemSelect(file)">
|
||||||
<div class="col-10">
|
<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">
|
<span class="name">
|
||||||
{{ file.name }}
|
{{ file.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-2 actions" v-if="fileActions.length">
|
<div class="col-2 actions" v-if="Object.keys(fileActions[file.path] || {})?.length">
|
||||||
<Dropdown>
|
<Dropdown :style="{'min-width': '11em'}">
|
||||||
<DropdownItem icon-class="fa fa-play" text="Play"
|
<DropdownItem
|
||||||
@input="$emit('play', {type: 'file', url: `file://${file.path}`})"
|
v-for="(action, key) in fileActions[file.path]"
|
||||||
v-if="hasPlay && file.type !== 'directory'" />
|
:key="key"
|
||||||
|
:icon-class="action.iconClass"
|
||||||
|
:text="action.text"
|
||||||
|
@input="action.onClick(file)"
|
||||||
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Loading from "@/components/Loading";
|
import BrowserOptions from "./Browser/Options";
|
||||||
import Utils from "@/Utils";
|
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
||||||
import MediaUtils from "@/components/Media/Utils";
|
|
||||||
import Dropdown from "@/components/elements/Dropdown";
|
import Dropdown from "@/components/elements/Dropdown";
|
||||||
import DropdownItem from "@/components/elements/DropdownItem";
|
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 {
|
export default {
|
||||||
name: "Browser",
|
emits: [
|
||||||
components: {DropdownItem, Dropdown, Loading},
|
'back',
|
||||||
|
'input',
|
||||||
|
'path-change',
|
||||||
|
'play',
|
||||||
|
],
|
||||||
mixins: [Utils, MediaUtils],
|
mixins: [Utils, MediaUtils],
|
||||||
emits: ['back', 'path-change', 'play', 'input'],
|
components: {
|
||||||
|
BrowserOptions,
|
||||||
|
ConfirmDialog,
|
||||||
|
DropdownItem,
|
||||||
|
Dropdown,
|
||||||
|
FileEditor,
|
||||||
|
FileInfo,
|
||||||
|
FileUploader,
|
||||||
|
Home,
|
||||||
|
Loading,
|
||||||
|
Modal,
|
||||||
|
TextPrompt,
|
||||||
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
hasBack: {
|
hasBack: {
|
||||||
|
@ -66,6 +240,11 @@ export default {
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hasSelectCurrentDirectory: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
initialPath: {
|
initialPath: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
@ -78,66 +257,440 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
filterTypes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
|
||||||
|
homepage: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
|
||||||
|
showDirectories: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
showFiles: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
copyFile: null,
|
||||||
path: this.initialPath,
|
directoryNotEmpty: false,
|
||||||
|
directoryToRemove: null,
|
||||||
|
editedFile: null,
|
||||||
|
editWarnings: [],
|
||||||
files: [],
|
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: {
|
computed: {
|
||||||
filteredFiles() {
|
displayedFileToRename() {
|
||||||
if (!this.filter?.length)
|
return this.fileToRename?.slice(this.path.length + 1)
|
||||||
return this.files
|
|
||||||
|
|
||||||
return this.files.filter((file) => (file?.name || '').toLowerCase().indexOf(this.filter.toLowerCase()) >= 0)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
hasPlay() {
|
editedFileName() {
|
||||||
return this.isMedia && this.files.some((file) => this.mediaExtensions.has(file.name.split('.').pop()?.toLowerCase()))
|
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() {
|
fileActions() {
|
||||||
if (!this.hasPlay)
|
return this.files.reduce((obj, file) => {
|
||||||
return []
|
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',
|
iconClass: 'fa fa-play',
|
||||||
text: 'Play',
|
text: 'Play',
|
||||||
onClick: (file) => this.$emit('play', {type: 'file', url: `file://${file.path}`}),
|
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() {
|
pathTokens() {
|
||||||
|
if (!this.path)
|
||||||
|
return []
|
||||||
|
|
||||||
if (!this.path?.length)
|
if (!this.path?.length)
|
||||||
return ['/']
|
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: {
|
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() {
|
async refresh() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
// Scroll to the end of the path navigator
|
// Scroll to the end of the path navigator
|
||||||
|
if (this.$refs.nav) {
|
||||||
this.$refs.nav.scrollLeft = 99999
|
this.$refs.nav.scrollLeft = 99999
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll to the top of the items list
|
// Scroll to the top of the items list
|
||||||
|
if (this.$refs.items) {
|
||||||
this.$refs.items.scrollTop = 0
|
this.$refs.items.scrollTop = 0
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
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.$emit('path-change', this.path)
|
||||||
this.setUrlArgs({path: decodeURIComponent(this.path)})
|
this.setUrlArgs({path: decodeURIComponent(this.path)})
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
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() {
|
onBack() {
|
||||||
|
@ -153,6 +706,14 @@ export default {
|
||||||
else
|
else
|
||||||
this.$emit('input', file.path)
|
this.$emit('input', file.path)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onSelectCurrentDirectory() {
|
||||||
|
this.$emit('input', this.path)
|
||||||
|
},
|
||||||
|
|
||||||
|
onUploadCompleted() {
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
|
@ -160,28 +721,50 @@ export default {
|
||||||
this.path = this.initialPath
|
this.path = this.initialPath
|
||||||
},
|
},
|
||||||
|
|
||||||
path() {
|
opts: {
|
||||||
|
deep: true,
|
||||||
|
handler() {
|
||||||
|
this.setUrlArgs(this.opts)
|
||||||
this.refresh()
|
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() {
|
mounted() {
|
||||||
const args = this.getUrlArgs()
|
const args = this.getUrlArgs()
|
||||||
if (args.path)
|
if (args.path)
|
||||||
this.path = args.path
|
this.path = args.path
|
||||||
|
|
||||||
|
this.initOpts()
|
||||||
this.refresh()
|
this.refresh()
|
||||||
},
|
},
|
||||||
|
|
||||||
unmounted() {
|
|
||||||
this.setUrlArgs({path: null})
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "src/style/items";
|
@import "src/style/items";
|
||||||
|
|
||||||
|
$btn-container-width: 1.5em;
|
||||||
|
|
||||||
.browser {
|
.browser {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -198,5 +781,102 @@ export default {
|
||||||
height: calc(100% - #{$nav-height});
|
height: calc(100% - #{$nav-height});
|
||||||
overflow: auto;
|
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>
|
</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