file upload works

This commit is contained in:
2021-12-18 16:24:07 +01:00
parent 07a0436ec8
commit e733b573f7
14 changed files with 501 additions and 206 deletions
+11 -10
View File
@@ -41,13 +41,6 @@ public class MediaController : ZeroApiTreeEntityStoreController<zero.Media.Media
Media = media;
}
[HttpPost("uploadtest")]
public async Task<ActionResult> Upload(IFormFile file)
{
await Task.Delay(2000);
return Ok();
}
#region bulk operations
[HttpPut("bulk/move")]
@@ -223,10 +216,18 @@ public class MediaController : ZeroApiTreeEntityStoreController<zero.Media.Media
[HttpPost("")]
[ZeroAuthorize(MediaPermissions.Create)]
public virtual Task<ActionResult<Result>> Create(zero.Media.Media saveModel)
public virtual async Task<ActionResult<Result>> Create([FromForm] IFormFile file, [FromForm] string folderId = null)
{
saveModel.IsFolder = false;
return CreateModel(saveModel);
Result<zero.Media.Media> result = await Media.UploadFile(file, folderId);
bool minimalResponse = Hints.ResponsePreference == ApiResponsePreference.Minimal;
if (result.IsSuccess)
{
return Created(GetAction(result.Model), minimalResponse ? null : result.Model);
}
return result.WithoutModel();
}
+5
View File
@@ -127,6 +127,11 @@ public class ApiResponseFilterAttribute : ResultFilterAttribute
ApiResponseMetadata GetMetadata(ResultExecutingContext context)
{
if (!context.HttpContext.Items.ContainsKey("zero.action.started"))
{
return new();
}
DateTimeOffset started = (DateTimeOffset)context.HttpContext.Items["zero.action.started"];
TimeSpan duration = DateTimeOffset.Now - started;
+3 -1
View File
@@ -25,6 +25,7 @@ import uiTableFilter from './ui-table-filter.vue';
import uiTrinity from './ui-trinity.vue';
import uiPick from './ui-pick.vue';
import uiDatagrid from './ui-datagrid.vue';
import uiProgress from './ui-progress.vue';
export {
uiIcon,
@@ -53,5 +54,6 @@ export {
uiTableFilter,
uiTrinity,
uiPick,
uiDatagrid
uiDatagrid,
uiProgress
};
@@ -0,0 +1,85 @@
<template>
<div class="ui-progress" :class="{'is-animated': animated }">
<span class="-value" :style="{ transform: 'translateX(' + ((100 - value) * -1) + '%)' }"></span>
</div>
</template>
<script>
export default {
name: 'uiProgress',
props: {
value: {
type: Number,
default: 0
},
animated: {
type: Boolean,
default: true
}
},
computed: {
progress()
{
return this.value > 100 ? 100 : (this.value < 0 ? 0 : this.value);
}
}
}
</script>
<style lang="scss">
.ui-progress
{
display: block;
width: 100%;
height: 12px;
border-radius: 6px;
background: var(--color-bg-shade-3);
position: relative;
overflow: hidden;
}
.ui-progress .-value
{
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
transform: translateX(-100%);
border-radius: 6px;
overflow: hidden;
background: var(--color-accent);
transition: transform 0.3s linear;
}
.ui-progress.is-animated .-value:before
{
--color-progress-overlay: #00000022;
content: '';
position: absolute;
left: -20px;
right: -20px;
top: 0;
bottom: 0;
background-image: linear-gradient(-60deg, transparent 25%, var(--color-progress-overlay) 25%, var(--color-progress-overlay) 50%, transparent 50%, transparent 75%, var(--color-progress-overlay) 75%, var(--color-progress-overlay));
background-size: 20px 30px;
animation: uiProgressAnimation 1s linear infinite;
}
@keyframes uiProgressAnimation
{
from
{
transform: translateX(-20px);
}
to
{
transform: translateX(0);
}
}
</style>
+8 -4
View File
@@ -11,7 +11,7 @@ const files = {
getHierarchy: (id: string, config?: ApiRequestConfig) => get(`media/${id}/hierarchy`, { ...config }),
create: (model: any, config?: ApiRequestConfig) => post('media', model, config),
//create: (model: any, config?: ApiRequestConfig) => post('media', model, config),
update: (model: any, config?: ApiRequestConfig) => put('media/' + model.id, model, config),
@@ -21,13 +21,17 @@ const files = {
//uploadtest: (model: any, config?: ApiRequestConfig) => post('media/uploadtest', model, config),
uploadtest: async (file, onProgress) =>
upload: async (file: File, folderId?: string, onProgress?: any) =>
{
var data = new FormData();
data.append('file', file);
//data.append('folderId', folderId);
return await post('media/uploadtest', data, {
if (folderId)
{
data.append('folderId', folderId);
}
return await post('media', data, {
onUploadProgress: (progressEvent) =>
{
if (typeof onProgress === 'function')
@@ -0,0 +1,225 @@
<template>
<div class="ui-media-upload-item" v-if="loaded">
<img class="-preview" v-if="preview" :src="preview" />
<span class="-preview" v-if="!preview"><ui-icon symbol="fth-file" :size="18" /><span class="-ext">{{extension}}</span></span>
<p class="ui-media-upload-item-text">
<span class="-text">{{file.name}}</span>
<span class="-minor">
<span v-filesize="file.size"></span>
<!--<span v-if="item.progress < 101 && !item.error && !item.success" class="ui-media-upload-item-progress">
<span class="-inner" :style="{ width: item.progress + '%' }"></span>
</span>-->
</span>
</p>
</div>
</template>
<script lang="ts">
import api from '../api';
import { generateId } from '../../../utils/numbers';
import { defineComponent } from 'vue';
export default defineComponent({
props: {
file: File
},
data: () => ({
preview: null,
extension: null,
loaded: false,
}),
watch: {
file: {
deep: true,
handler()
{
this.render();
}
}
},
mounted()
{
this.render();
},
beforeUnmount()
{
if (this.preview)
{
URL.revokeObjectURL(this.preview);
}
},
methods: {
render()
{
this.loaded = false;
if (!this.file)
{
return;
}
let name = this.file.name;
let dotIndex = name.lastIndexOf('.');
if (dotIndex > -1 && dotIndex + 6 > name.length)
{
this.extension = name.substring(dotIndex + 1, name.length);
}
if (this.file.type.indexOf('image/') === 0)
{
this.preview = URL.createObjectURL(this.file);
}
this.loaded = true;
}
}
//const files = this.model;
//console.info(files);
//let items = [];
//if (!files || files.length < 1)
//{
// return;
//}
//for (var i = 0; i < files.length; i++)
//{
// let file = files[i];
// let preview = null;
// if (file.type.indexOf('image/') === 0)
// {
// let reader = new FileReader();
// reader.onload = e => preview = e.target.result;
// reader.readAsDataURL(file);
// }
// this.items.push({
// id: 'upload:' + generateId(),
// name: file.name,
// size: file.size,
// mimeType: file.type,
// preview: preview,
// source: null,
// progress: 0,
// file: file,
// isImage: false,
// success: false,
// error: null
// });
//}
//console.info(this.items);
})
</script>
<style lang="scss">
.ui-media-upload-item
{
width: 100%;
height: auto;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 15px;
align-items: center;
line-height: 1.4;
& + .ui-media-upload-item
{
margin-top: 15px;
}
.-preview
{
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 50px;
width: 50px;
object-fit: cover;
background: var(--color-bg-shade-2);
border-radius: var(--radius);
position: relative;
text-align: center;
font-size: 22px;
}
/*&.is-upload .-preview
{
background: var(--color-primary);
color: var(--color-primary-text);
}*/
.-ext
{
font-size: 8px;
color: var(--color-text-dim);
font-style: normal;
margin-top: 4px;
text-transform: uppercase;
margin-bottom: -4px;
}
.-text
{
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
display: block;
overflow: hidden;
}
.-minor
{
display: block;
color: var(--color-text-dim);
font-size: var(--font-size-s);
}
}
.ui-media-upload-item-progress
{
display: block;
width: 50%;
height: 8px;
margin-top: 6px;
border-radius: 2px;
background: var(--color-bg-mid);
position: relative;
.-inner
{
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 2px;
background: var(--color-primary);
}
}
.ui-media-upload-item-text
{
display: flex;
flex-direction: column;
}
</style>
@@ -1,27 +1,20 @@
<template>
<div class="ui-media-upload">
<h2 class="ui-headline" v-localize="'Upload status'"></h2>
<h2 class="ui-headline" v-localize="'@media.upload.headline'"></h2>
<div class="ui-media-upload-items">
<button type="button" v-for="item in items" class="ui-media-upload-item">
<img class="-preview" v-if="item.isImage" :src="item.source" />
<span class="-preview" v-if="!item.isImage"><ui-icon symbol="fth-file" :size="20" /></span>
<p class="ui-media-upload-item-text">
{{item.name}}
<span class="-minor">
<br>
<span v-if="item.progress < 100 && !item.error && !item.success" class="ui-media-upload-item-progress">
<span class="-inner" :style="{ width: item.progress + '%' }"></span>
</span>
<span v-if="item.success">Completed</span>
</span>
</p>
</button>
<div class="ui-media-upload-progress">
<ui-progress :value="progress" :animated="!finished" />
<p class="ui-media-upload-progress-text" v-if="!finished">Uploading {{completed + 1}} of {{fileCount}} ...</p>
<p class="ui-media-upload-progress-text" v-else>Completed</p>
<!--<upload-status-item :file="model[4]" />-->
</div>
<div class="app-confirm-buttons">
<ui-button v-if="!disabled" type="action" label="Upload" @click="upload"></ui-button>
<ui-button type="light" label="@ui.close" :disabled="loading" @click="config.close"></ui-button>
<!--<div class="ui-media-upload-items">
<upload-status-item v-for="file in model.files" :file="file" />
</div>-->
<div class="app-confirm-buttons" v-if="finished && hasErrors">
<ui-button type="light" label="@ui.close" :disabled="loading" @click="config.confirm(null)"></ui-button>
</div>
</div>
</template>
@@ -30,84 +23,74 @@
<script lang="ts">
import api from '../api';
import { generateId } from '../../../utils/numbers';
import UploadStatusItem from './upload-status-item.vue';
export default {
props: {
model: FileList,
model: {
type: Object,
default: () => ({
files: [],
folderId: null
})
},
config: Object
},
data: () => ({
progress: 0,
completed: 0,
loading: false,
items: []
items: [],
hasErrors: false
}),
mounted()
components: { UploadStatusItem },
computed: {
fileCount()
{
return this.model.files.length;
},
finished()
{
return this.completed >= this.fileCount;
}
},
async mounted()
{
const files = this.model;
console.info(files);
let items = [];
if (!files || files.length < 1)
{
return;
}
for (var i = 0; i < files.length; i++)
{
let file = files[i];
this.items.push({
id: 'upload:' + generateId(),
name: file.name,
size: file.size,
mimeType: file.type,
source: null,
progress: 0,
file: file,
isImage: false,
success: false,
error: null
});
}
await this.upload();
},
methods: {
upload()
async upload()
{
for (var i = 0; i < this.items.length; i++)
const files = [...this.model.files];
const combinedSize = files.reduce((prev, current, idx) => prev + current.size, 0);
for (const file of files)
{
let item = this.items[i];
const factor = file.size / combinedSize;
const progressBefore = this.progress;
api.uploadtest(item.file, progress =>
await api.upload(file, this.model.folderId, progress =>
{
console.info({ name: item.file.name, progress });
item.progress = progress;
}).then(res =>
{
console.info('success');
//if (res.success)
//{
// item.source = res.model.thumbnailSource || res.model.source;
// item.isImage = res.model.type === 'image';
// item.success = true;
//}
//else
//{
// item.success = false;
// // TODO output error
//}
this.progress = progressBefore + progress * factor;
});
this.completed += 1;
}
},
onSubmit()
{
if (!this.hasErrors)
{
this.config.confirm(null);
}
}
}
}
@@ -136,74 +119,12 @@
overflow-y: auto;
}
.ui-media-upload-item
.ui-media-upload-progress-text
{
width: 100%;
height: 70px;
display: grid;
grid-template-columns: 70px 1fr;
gap: 15px;
align-items: center;
line-height: 1.4;
& + .ui-media-upload-item
{
margin-top: 15px;
}
.-preview
{
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 70px;
width: 70px;
object-fit: cover;
background: var(--color-bg-mid);
border-radius: var(--radius);
position: relative;
text-align: center;
font-size: 22px;
}
/*&.is-upload .-preview
{
background: var(--color-primary);
color: var(--color-primary-text);
}*/
.-extension
{
font-size: 12px;
font-style: normal;
margin-top: 8px;
}
.-minor
{
color: var(--color-text-light);
font-size: var(--font-size-s);
}
margin-top: var(--padding-xs);
font-size: var(--font-size-s);
color: var(--color-text-dim);
}
.ui-media-upload-item-progress
{
display: block;
width: 50%;
height: 8px;
margin-top: 6px;
border-radius: 2px;
background: var(--color-bg-mid);
position: relative;
.-inner
{
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 2px;
background: var(--color-primary);
}
}
</style>
@@ -1,7 +1,10 @@
<template>
<form enctype="multipart/form-data" class="media-overview-drop" :class="{ 'is-dragging': dragging }"
@dragenter.prevent="onDragEnter" @dragleave.prevent="onDragLeave" @dragover.prevent="" @drop.prevent.stop="onDrop">
<ui-icon symbol="fth-upload" :size="26" :stroke-width="2" />
<form enctype="multipart/form-data" class="media-overview-drop-outer" dropzone=""
@dragenter.prevent="onDragEnter" @dragleave.prevent="onDragLeave" @dragover.prevent="" @drop.prevent.stop="onDrop($event.dataTransfer.files)">
<div class="media-overview-drop" :class="{ 'is-dragging': dragging }">
<ui-icon symbol="fth-upload" :size="26" :stroke-width="2" />
<input type="file" multiple class="-input" @change="onDrop($event.target.files)" />
</div>
</form>
</template>
@@ -12,7 +15,10 @@
name: 'mediaOverviewDrop',
props: {
folderId: {
type: String,
required: false
}
},
data: () => ({
@@ -22,15 +28,24 @@
methods: {
async onDrop(ev)
async onDrop(files)
{
console.info(ev.dataTransfer);
this.dragging = false;
const result = await overlays.open({
component: () => import('../../overlays/upload-status.vue'),
model: ev.dataTransfer.files
width: 420,
softdismiss: false,
model: {
files,
folderId: this.folderId
}
});
if (result.eventType === 'confirm')
{
this.$emit('completed', files);
}
},
onDragEnter(ev)
@@ -60,11 +75,30 @@
box-shadow: var(--shadow-short);
border: 1px dashed var(--color-line-dashed-onbg);
color: var(--color-text);
position: relative;
cursor: pointer;
&.is-dragging
{
border-color: var(--color-accent);
}
.-input
{
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
height: 130px;
opacity: 0;
}
input[type=file],
input[type=file]::-webkit-file-upload-button
{
cursor: pointer;
}
}
.ui-datagrid-outer.is-selecting .media-overview-drop
@@ -26,7 +26,7 @@
<ui-datagrid ref="grid" v-model="gridConfig" @select="onSelected" @count="count = $event">
<template v-slot:before>
<media-drop />
<media-drop :folder-id="parentId" @completed="refresh" />
</template>
<template v-if="selected.length < 1" v-slot:actions="props">
@@ -125,6 +125,12 @@
},
async refresh()
{
await this.$refs.grid.update();
},
onSelected(selection)
{
this.selected = selection;
+10 -3
View File
@@ -4,7 +4,7 @@
<div class="app-overlay-outer" :display="instance.display" v-for="(instance, index) in instances" :key="instance.id"
:style="{ transform: instance.display !== 'editor' ? null : 'translateX(' + (editorLength - index - 1) * -120 + 'px)' }"
:class="instance.class || ''">
<div class="app-overlay-bg" @click="remove(instance)"></div>
<div class="app-overlay-bg" @click="tryRemove(instance)"></div>
<div open class="app-overlay" :data-alias="instance.alias" :style="{ width: instance.width ? (instance.width + 'px') : null }" :class="'theme-' + instance.theme" :display="instance.display">
<component :is="instance.component" :model.sync="instance.model" :config="instance" v-bind="instance" title=""></component>
</div>
@@ -57,6 +57,14 @@
this.instances.push(instance);
},
tryRemove(instance)
{
if (instance.softdismiss)
{
this.remove(instance);
}
},
remove(instance)
{
emitter.emit(event_finalizeOverlay, { eventType: 'close', instance, force: true });
@@ -121,7 +129,6 @@
border: none !important;
box-shadow: var(--shadow-overlay-dialog);
padding: var(--padding);
padding-top: 40px;
text-align: left;
position: relative;
-webkit-backface-visibility: hidden;
@@ -141,7 +148,7 @@
.app-overlay .ui-headline
{
margin-bottom: var(--padding-m);
margin-bottom: var(--padding);
}
.app-overlay[display="dialog"] .ui-form-loading
@@ -681,7 +681,12 @@
}
},
"child_count_1": "{count} item",
"child_count_x": "{count} items"
"child_count_x": "{count} items",
"upload": {
"headline": "Upload",
"multipleHeadline": "Uploading {count} items...",
"singleHeadline": "Uploading..."
}
},
"mailTemplate": {
+31 -39
View File
@@ -1,6 +1,10 @@
using SixLabors.ImageSharp;
using Shorthand.ImageSharp.WebP;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using System.IO;
namespace zero.Media;
@@ -64,50 +68,38 @@ public class MediaCreator : IMediaCreator
using Image<Rgba32> image = await Image.LoadAsync<Rgba32>(fileInfo.AbsolutePath);
model.ImageMeta = GetImageMetadata(image);
// TODO save thumbnails
string extension = Path.GetExtension(model.Path);
foreach ((string key, ResizeOptions opts) in Options.Thumbnails)
{
Image<Rgba32> imageFrame = image.Frames.Count > 1 ? image.Frames.CloneFrame(0) : image.Clone();
imageFrame.Mutate(x => x.Resize(opts));
using MemoryStream stream = new();
//image.Save(stream, new WebPEncoder()
//{
// Quality = 70
//});
await imageFrame.SaveAsync(stream, new JpegEncoder()
{
Quality = 80
}, cancellationToken);
stream.Position = 0;
string thumbFilename = normalizedFilename.TrimEnd(extension) + "." + Safenames.File(key) + ".jpg";
string path = directory + '/' + thumbFilename;
await FileSystem.CreateFile(path, stream, cancellationToken: cancellationToken);
model.ImageMeta.Thumbnails[key] = path;
}
}
return Result<Media>.Success(model);
}
//public virtual async Task<string> CreateImageThumbnails(Image<Rgba32> image, CancellationToken cancellationToken = default)
//{
// foreach ((string key, ResizeOptions opts) in Options.Thumbnails)
// {
// Image<Rgba32> imageFrame = image.Frames.Count > 1 ? image.Frames.CloneFrame(0) : image.Clone();
// imageFrame.Mutate(x => x.Resize(opts));
// }
// string source = SaveThumbnail(media, imageFrame, PREVIEW_EXTENSION, new ResizeOptions()
// {
// Size = new Size(210, 210),
// Mode = ResizeMode.Min
// });
// //media.ThumbnailSource = SaveThumbnail(media, imageFrame, THUMB_EXTENSION, new ResizeOptions()
// //{
// // Size = new Size(100, 100),
// // Mode = ResizeMode.Max
// //});
//}
/// <summary>
/// Saves a thumbnail of an image
/// </summary>
//public virtual string SaveThumbnail(Media media, Image<Rgba32> image, string extensionPrefix, ResizeOptions resizeOptions)
//{
// string extension = Path.GetExtension(media.Path);
// image.Mutate(x => x.Resize(resizeOptions));
// string thumbFileName = media.Name.TrimEnd(extension) + extensionPrefix + extension;
// image.Save()
// image.Save(Path.Combine(Paths.Media, media.FileId, thumbFileName));
// return Path.Combine(PATH_PREFIX, media.FileId, thumbFileName).Replace(Path.DirectorySeparatorChar, PATH_SEPARATOR);
//}
/// <inheritdoc />
protected virtual MediaImageMetadata GetImageMetadata(Image<Rgba32> image)
{
+8 -1
View File
@@ -85,7 +85,14 @@ public class MediaManagement : IMediaManagement
/// <inheritdoc />
public virtual async Task<Result<Media>> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default)
{
return await Creator.UploadFile(fileStream, filename, folderId, cancellationToken);
Result<Media> result = await Creator.UploadFile(fileStream, filename, folderId, cancellationToken);
if (!result.IsSuccess)
{
return result;
}
return await Store.Create(result.Model);
}
+1
View File
@@ -25,6 +25,7 @@
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
<PackageReference Include="RavenDB.Client" Version="5.2.4" />
<PackageReference Include="Shorthand.ImageSharp.WebP" Version="2.2.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
<PackageReference Include="System.Linq.Async" Version="5.0.0" />
</ItemGroup>