[#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:
Fabio Manganiello 2024-08-25 00:21:09 +02:00
parent e672a7fb5c
commit 0657c80a5c
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
9 changed files with 2382 additions and 49 deletions

View file

@ -3,21 +3,51 @@
<Loading v-if="loading" /> <Loading v-if="loading" />
<div class="nav" ref="nav"> <div class="nav" ref="nav">
<span class="path" <div class="path-container">
v-for="(token, i) in pathTokens" <span class="path" v-if="hasHomepage">
:key="i" <span class="token" @click="path = null">
@click="path = pathTokens.slice(0, i + 1).join('/').slice(1)"> <i class="fa fa-home" />
<span class="token"> </span>
{{ token }}
<span class="separator" v-if="pathTokens.length">
<i class="fa fa-chevron-right" />
</span>
</span> </span>
<span class="separator" v-if="(i > 0 || pathTokens.length > 1) && i < pathTokens.length - 1"> <span class="path"
<i class="fa fa-chevron-right" /> v-for="(token, i) in pathTokens"
:key="i"
@click="path = pathTokens.slice(0, i + 1).join('/').slice(1)">
<span class="token">
{{ token }}
</span>
<span class="separator"
v-if="(i > 0 || pathTokens.length > 1) && i < pathTokens.length - 1">
<i class="fa fa-chevron-right" />
</span>
</span> </span>
</span> </div>
<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> </div>
<div class="items" ref="items"> <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">&lt;Select This Directory&gt;</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] = {
iconClass: 'fa fa-play', play: {
text: 'Play', iconClass: 'fa fa-play',
onClick: (file) => this.$emit('play', {type: 'file', url: `file://${file.path}`}), 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() { 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
this.$refs.nav.scrollLeft = 99999 if (this.$refs.nav) {
this.$refs.nav.scrollLeft = 99999
}
// Scroll to the top of the items list // Scroll to the top of the items list
this.$refs.items.scrollTop = 0 if (this.$refs.items) {
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,9 +721,32 @@ export default {
this.path = this.initialPath 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() this.refresh()
}, },
showUpload(val) {
const uploader = this.$refs.uploader
if (val) {
uploader?.open()
this.$nextTick(() => {
uploader?.focus()
})
} else {
uploader?.close()
}
},
}, },
mounted() { mounted() {
@ -170,18 +754,17 @@ export default {
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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View 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" />&nbsp; 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>

View file

@ -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>