Changed file list

- Grid style more suited for large file listings
- Multi-select allows individual files to be deleted
This commit is contained in:
Mark van Renswoude 2018-12-09 17:26:23 +01:00
parent fbf6e6a96f
commit 10322bc26f
10 changed files with 2934 additions and 2234 deletions

View File

@ -1,6 +1,8 @@
const asyncHandler = require('express-async-handler');
const AuthTokens = require('../../authtokens');
const resolvePath = require('resolve-path');
const groupBy = require('lodash/groupBy');
const map = require('lodash/map');
module.exports = (repository, router) =>
@ -35,6 +37,20 @@ module.exports = (repository, router) =>
}));
router.delete('/fileuploads/', asyncHandler(async (req, res) =>
{
var groupedFiles = groupBy(req.body, (value) => value.uploadId);
for (var uploadId in groupedFiles)
{
await repository.uploads.deleteFiles(uploadId,
map(groupedFiles[uploadId], (file) => { return file.fileId }));
}
res.sendStatus(200);
}));
router.delete('/codeuploads/:code', asyncHandler(async (req, res) =>
{
var uploads = await repository.uploads.listForCode(req.params.code);

View File

@ -1,5 +1,7 @@
const map = require('lodash/map');
const filter = require('lodash/filter');
const intersectionWith = require('lodash/intersectionWith');
const pullAllWith = require('lodash/pullAllWith');
const resolvePath = require('resolve-path');
const fs = require('mz/fs');
const async = require('async');
@ -167,6 +169,8 @@ class UploadRepository
$set: {
userId: userId
}
}, {
multi: true
},
(err, numAffected) =>
{
@ -217,23 +221,7 @@ class UploadRepository
return;
}
await Promise.all(upload.files.map(async (file) =>
{
if (!file.id) return;
if (!(await self.fileExists(file.id)))
{
var fullpath = resolvePath(config.fileUpload.path, file.id);
try
{
await fs.unlink(fullpath);
}
catch (err)
{
console.log('Failed to delete ' + fullpath);
}
}
}));
await self.deleteOrphanedFiles(upload.files);
resolve();
});
});
@ -241,6 +229,102 @@ class UploadRepository
}
async deleteOrphanedFiles(files)
{
var self = this;
await Promise.all(files.map(async (file) =>
{
if (!file.id) return;
if (!(await self.fileExists(file.id)))
{
var fullpath = resolvePath(config.fileUpload.path, file.id);
try
{
await fs.unlink(fullpath);
}
catch (err)
{
console.log('Failed to delete ' + fullpath);
}
}
}));
}
deleteFiles(uploadId, fileIds)
{
var self = this;
return new Promise((resolve, reject) =>
{
self.store.findOne({ _id: uploadId }, (err, doc) =>
{
if (err)
{
reject(err);
return;
}
if (doc === null)
{
resolve();
return;
}
let upload = new Upload(doc);
let deletedFiles = intersectionWith(upload.files, fileIds, (arrVal, othVal) => { return arrVal.id == othVal; });
if (deletedFiles.length == 0)
{
resolve();
return;
}
pullAllWith(upload.files, fileIds, (arrVal, othVal) => { return arrVal.id == othVal; });
if (upload.files.length == 0)
{
// Remove entire upload
self.store.remove({ _id: uploadId }, async (err, numRemoved) =>
{
if (err)
{
reject(err);
return;
}
await self.deleteOrphanedFiles(deletedFiles);
resolve();
});
}
else
{
// Update file list
self.store.update({ _id: uploadId }, {
$set: {
files: map(filter(upload.files,
(file) => file.hasOwnProperty('id') && file.hasOwnProperty('name')),
(file) => { return { id: file.id, name: file.name, size: file.size } })
},
},
async (err, numRemoved) =>
{
if (err)
{
reject(err);
return;
}
await self.deleteOrphanedFiles(deletedFiles);
resolve();
});
}
});
});
}
fileExists(fileId)
{
var self = this;

4336
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,40 +16,41 @@
"author": "Mark van Renswoude <mark@x2software.net>",
"license": "Unlicense",
"dependencies": {
"@fortawesome/fontawesome": "^1.1.5",
"@fortawesome/fontawesome-free-solid": "^5.0.10",
"@fortawesome/fontawesome": "^1.1.8",
"@fortawesome/fontawesome-free-regular": "^5.0.13",
"@fortawesome/fontawesome-free-solid": "^5.0.13",
"@fortawesome/vue-fontawesome": "0.0.22",
"async": "^2.6.0",
"async-retry": "^1.2.1",
"async": "^2.6.1",
"async-retry": "^1.2.3",
"babel-polyfill": "^6.26.0",
"bcrypt": "^1.0.3",
"body-parser": "^1.18.2",
"body-parser": "^1.18.3",
"cookie-parser": "^1.4.3",
"debug": "^3.1.0",
"debug": "^3.2.5",
"diskusage": "^0.2.4",
"ejs": "^2.5.9",
"email-templates": "^3.6.0",
"es6-promise": "^4.2.4",
"ejs": "^2.6.1",
"email-templates": "^3.6.1",
"es6-promise": "^4.2.5",
"express": "^4.16.3",
"express-async-handler": "^1.1.3",
"express-async-handler": "^1.1.4",
"js-cookie": "^2.2.0",
"jsonwebtoken": "^8.2.1",
"lodash": "^4.17.10",
"jsonwebtoken": "^8.3.0",
"lodash": "^4.17.11",
"markdown": "^0.5.0",
"mkdirp": "^0.5.1",
"moment": "^2.22.1",
"moment": "^2.22.2",
"mz": "^2.7.0",
"nanoid": "^1.0.2",
"nanoid": "^1.2.3",
"nedb": "^1.8.0",
"nodemailer": "^4.6.4",
"npm": "^5.8.0",
"nodemailer": "^4.6.8",
"npm": "^5.10.0",
"pug": "^2.0.3",
"resolve-path": "^1.4.0",
"tus-node-server": "^0.2.11"
},
"devDependencies": {
"axios": "^0.18.0",
"babel-loader": "^7.1.4",
"babel-loader": "^7.1.5",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-plugin-transform-es2015-arrow-functions": "^6.22.0",
"babel-plugin-transform-es2015-block-scoping": "^6.26.0",
@ -57,22 +58,22 @@
"css-loader": "^0.28.11",
"file-loader": "^1.1.11",
"html-webpack-plugin": "^3.2.0",
"node-sass": "^4.9.0",
"node-sass": "^4.9.3",
"purecss": "^1.0.0",
"sass-loader": "^6.0.7",
"style-loader": "^0.20.3",
"tus-js-client": "^1.5.1",
"uppy": "^0.23.3",
"vue": "^2.5.16",
"vue-i18n": "^7.6.0",
"vue-loader": "^14.2.2",
"vue": "^2.5.17",
"vue-i18n": "^7.8.1",
"vue-loader": "^14.2.3",
"vue-router": "^3.0.1",
"vue-style-loader": "^4.1.0",
"vue-template-compiler": "^2.5.16",
"webpack": "^4.6.0",
"webpack-bundle-analyzer": "^2.11.1",
"webpack-cli": "^2.1.2",
"webpack-dev-middleware": "^3.1.3",
"webpack-hot-middleware": "^2.22.1"
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.19.1",
"webpack-bundle-analyzer": "^2.13.1",
"webpack-cli": "^2.1.5",
"webpack-dev-middleware": "^3.3.0",
"webpack-hot-middleware": "^2.24.0"
}
}

View File

@ -193,4 +193,10 @@ a
{
color: red !important;
}
.pure-button-confirm-delete
{
background: rgb(223, 117, 20);
color: white;
}
</style>

View File

@ -17,6 +17,8 @@ import faSpinner from '@fortawesome/fontawesome-free-solid/faSpinner';
import faBan from '@fortawesome/fontawesome-free-solid/faBan';
import faTrashAlt from '@fortawesome/fontawesome-free-solid/faTrashAlt';
import faUser from '@fortawesome/fontawesome-free-solid/faUser';
import faSquare from '@fortawesome/fontawesome-free-regular/faSquare';
import faCheckSquare from '@fortawesome/fontawesome-free-regular/faCheckSquare';
if (typeof customMessages !== 'undefined')
@ -34,7 +36,7 @@ if (typeof customMessages !== 'undefined')
}
fontawesome.library.add(faSpinner, faBan, faTrashAlt, faUser);
fontawesome.library.add(faSpinner, faBan, faTrashAlt, faUser, faSquare, faCheckSquare);
Vue.component('fa', FontAwesomeIcon);
Vue.use(VueI18n);

View File

@ -48,6 +48,7 @@ export default {
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
deleteApply: 'Delete selection',
diskspace: '{available} disk space available of {total} total',
@ -59,7 +60,7 @@ export default {
},
menu: {
uploads: 'Uploads',
uploads: 'Received',
codes: 'Codes',
users: 'Users'
},
@ -68,6 +69,7 @@ export default {
created: 'Date',
code: 'Code',
owner: 'Owner',
expiration: 'Expires:',
userDeleted: 'deleted',
assign: 'Change owner',

View File

@ -48,6 +48,7 @@ export default {
cancel: 'Annuleren',
save: 'Opslaan',
delete: 'Verwijderen',
deleteApply: 'Selectie verwijderen',
diskspace: '{available} schijfruimte beschikbaar van {total} totaal',
@ -59,7 +60,7 @@ export default {
},
menu: {
uploads: 'Uploads',
uploads: 'Ontvangen',
codes: 'Codes',
users: 'Gebruikers'
},
@ -68,11 +69,12 @@ export default {
created: 'Datum',
code: 'Code',
owner: 'Eigenaar',
expiration: 'Verloopt:',
userDeleted: 'verwijderd',
assign: 'Verander eigenaar',
assignApply: 'Toepassen'
},
},
codes: {
add: 'Genereer code',

View File

@ -2,45 +2,22 @@
<div id="uploads">
<div v-if="uploads !== null" class="list">
<div v-for="group in groupedUploads">
<template v-for="(upload, index) in group.uploads">
<div class="properties" v-if="index == 0">
<div class="pure-g">
<div class="pure-u-1-1"><span class="text codedescription">{{ upload.codedescription || upload.codeId }}</span></div>
<div class="pure-u-1-3"><span class="text">{{ upload.codeId }}</span></div>
<div class="pure-u-1-3">
<span class="text" v-if="hasAuth('viewAllUploads')" :class="{ userDeleted: !upload.username }">
{{ upload.username || $t('admin.uploads.userDeleted') }}
</span>
</div>
<div class="pure-u-1-3 right"><span class="text">{{ upload.created | formatDateTime }}</span></div>
<div class="properties">
<div class="pure-g">
<div class="pure-u-1-1"><span class="text codedescription">{{ group.lastUpload.codedescription || group.lastUpload.codeId }}</span></div>
<div class="pure-u-1-3"><span class="text">{{ group.lastUpload.codeId }}</span></div>
<div class="pure-u-1-3">
<span class="text" v-if="hasAuth('viewAllUploads')" :class="{ userDeleted: !group.lastUpload.username }">
{{ group.lastUpload.username || $t('admin.uploads.userDeleted') }}
</span>
</div>
<div class="pure-u-1-3 right">
<span class="text" v-if="group.lastUpload.expirationDate">{{ $t('admin.uploads.expiration') }} {{ group.lastUpload.expirationDate | formatDateTime }}</span>
</div>
</div>
</div>
<div class="pure-menu pure-menu-horizontal" v-if="index == 0">
<ul class="pure-menu-list">
<li class="pure-menu-item"><a href="#" class="pure-menu-link" @click.prevent="showAssign = !showAssign"><fa icon="user"></fa> {{ $t('admin.uploads.assign') }}</a></li>
<li class="pure-menu-item" v-if="confirmDelete == upload.codeId"><a href="#" class="pure-menu-link" @click.prevent="cancelDelete"><fa icon="ban"></fa> {{ $t('admin.cancel') }}</a></li>
<li class="pure-menu-item"><a href="#" class="pure-menu-link" :class="{ confirmDelete: confirmDelete == upload.codeId }" @click.prevent="deleteClick(upload.codeId)"><fa icon="trash-alt"></fa> {{ $t('admin.delete') }}</a></li>
</ul>
</div>
<div v-if="showAssign" class="assign pure-form pure-form-horizontal">
<select v-model="assignUser">
<option v-for="user in assignUsers" v-if="user.id !== upload.userId" :value="user.id">{{ user.name }}</option>
</select>
<button class="pure-button pure-button-primary" @click.prevent="assignCode(upload.codeId)">{{ $t('admin.uploads.assignApply') }}</button>
<button class="pure-button" @click.prevent="showAssign = false">{{ $t('admin.cancel') }}</button>
</div>
<div class="file" v-for="file in upload.files" :title="file.name">
<a :href="getDownloadUrl(file)">
<img :src="getFileIconUrl(file.name)" class="icon">
<span class="filename">{{ file.name }}</span>
<span class="size">{{ file.size | formatSizeSI }}</span>
</a>
</div>
</template>
<UploadFiles :group="group" v-bind:assignUsers="assignUsers" @files-deleted="onFilesDeleted" @code-assigned="onCodeAssigned"></UploadFiles>
</div>
<div v-if="uploads.length == 0" class="nodata">
@ -55,6 +32,7 @@
</template>
<script>
import UploadFiles from './components/UploadFiles.vue';
import orderBy from 'lodash/orderBy';
import findIndex from 'lodash/findIndex';
import forEach from 'lodash/forEach';
@ -62,13 +40,14 @@ import axios from 'axios';
import shared from '../../shared';
export default {
components: {
UploadFiles
},
data()
{
return {
uploads: null,
confirmDelete: null,
showAssign: false,
assignUser: null,
assignUsers: []
};
},
@ -122,7 +101,8 @@ export default {
{
codes[upload.codeId] = {
maxCreated: upload.created,
uploads: [upload]
uploads: [upload],
lastUpload: upload
};
}
else
@ -142,95 +122,43 @@ export default {
},
getFileIconUrl(filename)
{
var ext = this.getExtension(filename);
if (ext == '')
ext = '_blank';
return '/images/fileicons/32px/' + ext + '.png';
},
getExtension(filename)
{
var parts = filename.split('.');
return parts.length > 0 ? parts.pop() : '';
},
getDownloadUrl(file)
{
return '/admin/download/' + encodeURIComponent(file.id) + '/' + encodeURIComponent(file.name);
},
deleteClick(codeId)
onFilesDeleted(files)
{
var self = this;
if (self.confirmDelete == codeId)
for (let i = self.uploads.length - 1; i >= 0; i--)
{
self.confirmDelete = null;
let upload = self.uploads[i];
axios.delete('/admin/codeuploads/' + encodeURIComponent(codeId), {
headers: {
Authorization: 'Bearer ' + shared.adminToken
}})
.then((response) =>
for (let f = upload.files.length - 1; f >= 0; f--)
{
if (findIndex(files, { uploadId: upload.id, fileId: upload.files[f].id }) > -1)
{
})
.catch((error) => { shared.$emit('apiError', error, this.$router) });
upload.files.splice(f, 1);
}
}
if (upload.files.length == 0)
self.uploads.splice(i, 1);
}
},
onCodeAssigned(codeId, userId)
{
var self = this;
if (self.hasAuth('viewAllUploads'))
{
let username = null;
forEach(self.assignUsers, (user) =>
{
if (user.id == userId)
username = user.name;
})
self.updateCodeUser(codeId, userId, username);
}
else
{
self.confirmDelete = codeId;
}
},
cancelDelete()
{
var self = this;
self.confirmDelete = null;
},
assignCode(codeId)
{
var self = this;
let userId = self.assignUser;
if (!userId)
return;
self.assignUser = null;
self.showAssign = false;
axios.post('/admin/assign/code', {
id: codeId,
userId: userId
}, {
headers: {
Authorization: 'Bearer ' + shared.adminToken
}
})
.then((response) =>
{
if (self.hasAuth('viewAllUploads'))
{
let username = null;
forEach(self.assignUsers, (user) =>
{
if (user.id == userId)
username = user.name;
})
self.updateCodeUser(codeId, userId, username);
}
else
self.removeCodeUploads(codeId);
})
.catch((error) => { shared.$emit('apiError', error, this.$router) })
self.removeCodeUploads(codeId);
},
@ -264,53 +192,9 @@ export default {
</script>
<style lang="scss">
.file
.right
{
display: inline-block;
width: 10rem;
padding: .5rem;
margin-top: .5rem;
border: solid 1px transparent;
font-size: 75%;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
a
{
color: black;
text-decoration: none;
}
.icon
{
display: block;
margin-bottom: .5rem;
margin-left: auto;
margin-right: auto;
}
.filename, .size
{
display: block;
}
.size
{
color: #808080;
}
&:hover
{
background-color: #d3e9f8;
border: solid 1px #a7d3f1;
}
text-align: right;
}
@ -319,16 +203,4 @@ export default {
background-color: #f4f4f4;
padding: .2rem;
}
.codedescription
{
font-weight: bold;
}
.assign
{
margin-top: .5rem;
}
</style>

View File

@ -0,0 +1,389 @@
<template>
<div class="uploadFiles">
<div class="pure-menu pure-menu-horizontal toolbar">
<ul class="pure-menu-list">
<li class="pure-menu-item"><a href="#" class="pure-menu-link" @click.prevent="startAssign()"><fa icon="user"></fa> {{ $t('admin.uploads.assign') }}</a></li>
<li class="pure-menu-item"><a href="#" class="pure-menu-link" @click.prevent="startDelete()"><fa icon="trash-alt"></fa> {{ $t('admin.delete') }}</a></li>
</ul>
</div>
<div v-if="showAssign" class="confirmToolbar pure-form pure-form-horizontal">
<select v-model="assignUser">
<option v-for="user in assignUsers" v-if="user.id !== group.lastUpload.userId" :value="user.id">{{ user.name }}</option>
</select>
<button class="pure-button pure-button-primary" @click.prevent="confirmAssign()" :disabled="!hasAssignUser"><fa icon="spinner" spin v-if="assigning"></fa> {{ $t('admin.uploads.assignApply') }}</button>
<button class="pure-button" @click.prevent="cancelAssign()">{{ $t('admin.cancel') }}</button>
</div>
<div v-if="showDelete" class="confirmToolbar pure-form pure-form-horizontal">
<button class="pure-button pure-button-confirm-delete" @click.prevent="confirmDelete()" :disabled="!isAnySelected"><fa icon="spinner" spin v-if="deleting"></fa> {{ $t('admin.deleteApply') }}</button>
<button class="pure-button" @click.prevent="cancelDelete()">{{ $t('admin.cancel') }}</button>
<button class="pure-button separator" @click.prevent="selectAll()"><fa :icon="['far', 'check-square']"></fa></button>
<button class="pure-button" @click.prevent="selectNone()"><fa :icon="['far', 'square']"></fa></button>
</div>
<template v-for="upload in group.uploads">
<template v-for="file in orderedFiles(upload.files)">
<div class="pure-g file" @click.prevent="fileClick(upload, file)" :title="file.name">
<div class="pure-u-1-2 filename">
<input type="checkbox" v-if="showDelete" v-bind:checked="getSelected(upload, file)" @input="(v) => setSelected(upload, file, v)" />
<a :href="getDownloadUrl(file)"><img :src="getFileIconUrl(file.name)" class="icon"> {{ file.name }}</a>
</div>
<div class="pure-u-1-4 date">
{{ upload.created | formatDateTime }}
</div>
<div class="pure-u-1-4 size">
{{ file.size | formatSizeSI }}
</div>
</div>
</template>
</template>
</div>
</template>
<script>
import axios from 'axios';
import shared from '../../../shared';
import orderBy from 'lodash/orderBy';
import forEach from 'lodash/forEach';
export default {
props: ['group', 'assignUsers'],
data()
{
return {
showDelete: false,
deleting: false,
showAssign: false,
assigning: false,
assignUser: null,
selection: {}
}
},
computed: {
hasAssignUser()
{
var self = this;
return self.assignUser !== null;
},
isAnySelected()
{
var self = this;
for (var fileId in self.selection)
{
if (self.selection.hasOwnProperty(fileId) && self.selection[fileId])
return true;
}
return false;
}
},
methods: {
orderedFiles(files)
{
return orderBy(files, ['name'], ['asc']);
},
getFileIconUrl(filename)
{
var ext = this.getExtension(filename);
if (ext == '')
ext = '_blank';
return '/images/fileicons/16px/' + ext + '.png';
},
getExtension(filename)
{
var parts = filename.split('.');
return parts.length > 0 ? parts.pop() : '';
},
getDownloadUrl(file)
{
return '/admin/download/' + encodeURIComponent(file.id) + '/' + encodeURIComponent(file.name);
},
getSelected(upload, file)
{
var self = this;
let uniqueId = upload.id + ';' + file.id;
if (typeof self.selection[uniqueId] === 'undefined')
self.$set(self.selection, uniqueId, true);
return self.selection[uniqueId];
},
setSelected(upload, file, value)
{
var self = this;
let uniqueId = upload.id + ';' + file.id;
if (typeof self.selection[uniqueId] === 'undefined')
self.$set(self.selection, uniqueId, value);
else
self.selection[uniqueId] = value;
},
fileClick(upload, file)
{
var self = this;
if (self.showDelete)
self.setSelected(upload, file, !self.getSelected(upload, file));
else
location.href = self.getDownloadUrl(file);
},
startDelete()
{
var self = this;
if (self.showDelete)
self.cancelDelete();
else
{
self.showDelete = !self.showDelete;
self.showAssign = false;
}
},
confirmDelete()
{
var self = this;
if (self.deleting)
return;
let deleteFiles = [];
forEach(self.group.uploads, (upload) =>
{
forEach(upload.files, (file) =>
{
if (self.getSelected(upload, file))
{
deleteFiles.push({
uploadId: upload.id,
fileId: file.id
});
}
});
});
self.deleting = true;
axios.delete('/admin/fileuploads/', {
data: deleteFiles,
headers: {
Authorization: 'Bearer ' + shared.adminToken
}})
.then((response) =>
{
self.$emit('files-deleted', deleteFiles);
})
.catch((error) => { shared.$emit('apiError', error, this.$router) })
.then(() =>
{
self.deleting = false;
self.showDelete = false;
self.selection = {};
});
},
setAllSelected(value)
{
var self = this;
for (var fileId in self.selection)
{
if (self.selection.hasOwnProperty(fileId))
self.selection[fileId] = value;
}
},
selectAll()
{
var self = this;
self.setAllSelected(true);
},
selectNone()
{
var self = this;
self.setAllSelected(false);
},
cancelDelete()
{
var self = this;
self.showDelete = false
self.selection = {};
},
startAssign()
{
var self = this;
if (self.showAssign)
self.cancelAssign();
else
{
self.showAssign = !self.showAssign;
self.showDelete = false;
}
},
confirmAssign()
{
var self = this;
if (self.assigning)
return;
let userId = self.assignUser;
if (!userId)
return;
let codeId = self.group.lastUpload.codeId;
self.assignUser = null;
self.assigning = false;
axios.post('/admin/assign/code', {
id: codeId,
userId: userId
}, {
headers: {
Authorization: 'Bearer ' + shared.adminToken
}
})
.then((response) =>
{
self.$emit('code-assigned', codeId, userId);
})
.catch((error) => { shared.$emit('apiError', error, this.$router) })
.then(() =>
{
self.assigning = false;
self.showAssign = false;
})
},
cancelAssign()
{
var self = this;
self.showAssign = false;
self.assignUser = null;
}
}
}
</script>
<style lang="scss">
.file
{
border: solid 1px transparent;
font-size: 75%;
padding: .5rem;
vertical-align: middle;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
a
{
color: black;
text-decoration: none;
}
.icon
{
display: inline-block;
}
.date
{
text-align: right;
}
.size
{
color: #808080;
text-align: right;
}
&:hover
{
background-color: #d3e9f8;
border: solid 1px #a7d3f1;
}
}
.upload
{
background-color: #fafafa;
border-bottom: solid 1px #f4f4f4;
padding: .2rem;
}
.codedescription
{
font-weight: bold;
}
.toolbar
{
border-bottom: solid 2px #f0f0f0;
}
.confirmToolbar
{
margin-top: .5rem;
margin-bottom: .5rem;
font-size: 75%;
}
.separator
{
margin-left: 2rem;
}
</style>