Commit 4aa1e817 authored by seykron's avatar seykron

Adds support to upload and preview media

parent 6dc73e07
Pipeline #178 failed with stages
......@@ -2,7 +2,7 @@
<div class="container">
<div class="row">
<div class="col s12 l8 offset-l2">
<div class="steps center-align">
<div class="steps center-align" v-if="draft">
<button-step :create-campaign="draft" :step="Steps.SELECT_TITLE"/>
<button-step :create-campaign="draft" :step="Steps.SELECT_TARGETS"/>
<button-step :create-campaign="draft" :step="Steps.WRITE_DESCRIPTION"/>
......@@ -12,20 +12,20 @@
<router-view></router-view>
<select-title
v-if="draft.step.view === 'selectTitle'"
v-if="draft && draft.step.view === 'selectTitle'"
:create-campaign="draft"
/>
<select-targets
v-if="draft.step.view === 'selectTargets'"
v-if="draft && draft.step.view === 'selectTargets'"
:create-campaign="draft"
/>
<write-description
v-if="draft.step.view === 'writeDescription'"
v-if="draft && draft.step.view === 'writeDescription'"
:create-campaign="draft"
/>
<select-media
v-if="draft.step.view === 'selectMedia'"
:create-campaign="draft"
v-if="draft && draft.step.view === 'selectMedia'"
:campaign="draft"
/>
</div>
</div>
......@@ -47,6 +47,7 @@ export default {
name: 'CreateCampaign',
components: { ButtonStep, SelectTitle, SelectTargets, WriteDescription, SelectMedia },
async destroyed() {
await this.draft.syncMedia();
await campaignRepository.saveDraft(this.draft);
},
data() {
......@@ -55,7 +56,7 @@ export default {
Steps
};
},
async created() {
async beforeMount() {
this.draft = await campaignRepository.currentDraft();
},
computed: {
......
......@@ -2,7 +2,7 @@
<div class="section right-align">
<button
class="waves-effect waves-red btn"
@click="createCampaign.nextStep()"
@click="campaign.nextStep()"
>
{{ $t('createCampaign.action.next') }}
</button>
......@@ -20,7 +20,7 @@ import { DraftCampaign } from '../model/DraftCampaign'
export default {
name: 'ButtonNext',
props: {
createCampaign: {
campaign: {
type: DraftCampaign,
required: true
}
......
......@@ -4,12 +4,24 @@
<p>{{ $t('createCampaign.selectMedia.description') }}</p>
<div class="drop-container card-panel center-align">
<a class="btn-floating btn-large waves-effect waves-light red remove-media"
v-if="this.mediaUrl"
v-if="mediaObject"
@click="removeMedia"
>
<i class="material-icons">clear</i>
</a>
<div v-if="!this.mediaUrl">
<a class="btn-floating btn-large waves-effect waves-light red edit-image"
v-if="!editMode && mediaObject && mediaObject.baseType === 'image'"
@click="editImage"
>
<i class="material-icons">edit</i>
</a>
<a class="btn-floating btn-large waves-effect waves-light red edit-image"
v-if="editMode && mediaObject && mediaObject.baseType === 'image'"
@click="saveImage"
>
<i class="material-icons">save</i>
</a>
<div v-if="!mediaObject">
<p>
<i class="material-icons medium">cloud_upload</i>
</p>
......@@ -20,10 +32,10 @@
{{ $t('createCampaign.selectMedia.upload') }}
</label>
</div>
<div class="media-object" v-if="this.mediaUrl">
<img id="imageObject" v-if="this.mediaTypeBase === 'image'" v-bind:src="this.mediaUrl">
<div class="media-object" v-if="mediaObject">
<img id="imageObject" v-if="mediaObject.baseType === 'image'" v-bind:src="mediaObject.url">
<div
v-if="this.mediaTypeBase === 'image'"
v-if="editMode && mediaObject.baseType === 'image'"
class="toolbar"
>
<button data-action="move" title="Move" @click="cropMode">
......@@ -51,12 +63,12 @@
<span class="fa fa-arrows-v"></span>
</button>
</div>
<video v-if="this.mediaTypeBase === 'video'" controls>
<source v-bind:src="this.mediaUrl" v-bind:type="this.mediaType">
<video v-if="mediaObject.baseType === 'video'" controls>
<source v-bind:src="mediaObject.url" v-bind:type="mediaObject.type">
</video>
</div>
</div>
<button-next :create-campaign="createCampaign" />
<button-next :campaign="campaign" />
</div>
</template>
......@@ -70,48 +82,61 @@ export default {
name: 'SelectMedia',
components: { ButtonNext },
props: {
createCampaign: {
campaign: {
type: DraftCampaign,
required: true
}
},
data() {
return {
mediaUrl: null,
mediaType: null,
mediaTypeBase: null,
mediaBlob: null,
editMode: false,
cropper: null
};
},
computed: {
mediaObject() {
return this.campaign.media;
}
},
methods: {
displayMedia({ target }) {
const file = target.files[0];
if (this.mediaUrl) {
URL.revokeObjectURL(this.mediaUrl);
}
this.mediaUrl = URL.createObjectURL(file);
this.mediaType = file.type;
this.mediaTypeBase = file.type.substr(0, file.type.indexOf('/'));
if (this.mediaTypeBase === 'image') {
this.attachCropper();
}
this.campaign.updateMedia(target.files[0]);
},
removeMedia() {
if (this.mediaUrl) {
URL.revokeObjectURL(this.mediaUrl);
}
if (this.cropper) {
this.cropper.destroy();
}
this.mediaUrl = null;
this.mediaType = null;
this.mediaTypeBase = null;
this.mediaBlob = null;
this.campaign.removeMedia();
this.cropper = null;
this.editMode = false;
},
editImage() {
if (this.editMode) {
return;
}
this.attachCropper();
this.editMode = true;
},
async saveImage() {
const { cropper, campaign } = this;
const component = this;
return new Promise((resolve => {
if (cropper) {
cropper.getCroppedCanvas(campaign.media.type === 'image/png' ? {} : {
fillColor: '#fff',
}).toBlob(blob => {
const name = campaign.media.name;
component.removeMedia();
campaign.updateMedia(
new File([blob], name, { type: blob.type })
);
resolve();
});
} else {
component.editMode = false;
resolve();
}
}))
},
attachCropper() {
const component = this;
......@@ -145,18 +170,6 @@ export default {
flipVertical() {
const { cropper } = this;
cropper.scaleY(-cropper.getData().scaleY || -1);
},
crop() {
const { cropper, mediaType } = this;
if (cropper) {
this.croppedData = cropper.getData();
this.canvasData = cropper.getCanvasData();
this.cropBoxData = cropper.getCropBoxData();
this.mediaBlob = cropper.getCroppedCanvas(mediaType === 'image/png' ? {} : {
fillColor: '#fff',
}).toDataURL(mediaType);
}
}
}
}
......@@ -186,12 +199,18 @@ input[type="file"] {
right: 0;
}
.drop-container .edit-image {
position: absolute;
right: 0;
top: 90px;
}
.drop-container .material-icons {
color: #cfd8dc;
}
.media-object img {
display: block;
display: inline-block;
max-height: 350px;
max-width: 100%;
}
......
......@@ -19,7 +19,7 @@
{{ $t('createCampaign.selectTargets.tooltip') }}
</span>
</div>
<button-next :create-campaign="createCampaign" />
<button-next :campaign="createCampaign" />
</div>
</template>
......
......@@ -9,7 +9,7 @@
>
{{ $t('createCampaign.selectTitle.tooltip') }}
</span>
<button-next :create-campaign="createCampaign" />
<button-next :campaign="createCampaign" />
</div>
</template>
......
......@@ -12,7 +12,7 @@
@change="onEditorChange"
/>
<button-next :create-campaign="createCampaign" />
<button-next :campaign="createCampaign" />
<div
class="help-card card-panel teal blue-grey lighten-4"
......
import { MediaManager } from '../../media';
import { put } from "../../../modules/objectCache";
import { MediaObject } from "../../media/model/MediaObject";
export const Steps = {
SELECT_TITLE: {
view: 'selectTitle',
......@@ -59,6 +63,7 @@ export class DraftCampaign {
stepName = 'SELECT_TITLE',
title,
description,
media,
selectedTargets = [],
updatedAt
}) {
......@@ -70,6 +75,7 @@ export class DraftCampaign {
this.step =
sortedSteps().find((step) => step.view === this.stepName) ||
Steps.SELECT_TITLE;
this.media = media && put(new MediaObject(media));
this._allTargets = MOCK_TARGETS;
this.updatedAt = updatedAt || new Date();
}
......@@ -134,4 +140,29 @@ export class DraftCampaign {
)
.filter(Boolean);
}
updateMedia(file) {
this.media = MediaManager.upload(file);
}
removeMedia() {
if (this.media) {
MediaManager.cancelUpload(this.media.id);
this.media = null;
}
}
/** If the media upload is still in progress, it cancels the upload, otherwise
* it updates the campaign's media with the uploaded MediaObject.
*/
async syncMedia() {
if (!this.media) {
return;
}
if (MediaManager.inProgress(this.media.id)) {
this.removeMedia();
} else {
this.media = await MediaManager.findById(this.media.id);
}
}
}
import { put, get } from '../../modules/objectCache';
import axios from 'axios';
import { AsyncRequest, RequestStatus } from "../../modules/AsyncRequest";
import { MediaObject } from "./model/MediaObject";
/** Map from request id to active request.
* @type {Map<string, ActiveRequest>}
*/
const activeRequests = new Map();
function generateId(file) {
const { name, type, size } = file;
return `${name}!${type}!${size}`;
}
class ActiveRequest {
/**
*
* @param {MediaObject} localMediaObject
* @param {AsyncRequest} asyncRequest
*/
constructor({ localMediaObject, asyncRequest }) {
this._localMediaObject = localMediaObject;
this._asyncRequest = asyncRequest;
const activeRequest = this;
asyncRequest.onSuccess(response => {
activeRequest._mediaObject = put(new MediaObject(response.data));
});
}
cancel() {
this._asyncRequest.cancel();
}
revoke() {
URL.revokeObjectURL(this._localMediaObject.url)
}
get inProgress() {
return this._asyncRequest.status === RequestStatus.PENDING;
}
get done() {
return this._asyncRequest.status === RequestStatus.DONE;
}
get valid() {
return this.inProgress || this.done;
}
get mediaObject() {
return this._mediaObject || this._localMediaObject;
}
}
export class MediaManager {
/** Constructs an upload manager to upload files to the specified endpoint.
* @param {string} contentEndpoint Base endpoint to upload and read content.
*/
constructor(contentEndpoint) {
this._contentEndpoint = contentEndpoint;
}
/** Checks whether the specified media object is being uploaded.
* @param {string} id Content identifier to verify.
* @return {boolean} true if the media object is being uploaded, false otherwise.
*/
inProgress(id) {
return activeRequests.has(id) && activeRequests.get(id).inProgress;
}
/** Returns a MediaObject by id.
* @param {string} id Content identifier.
* @return {Promise<MediaObject>} a promise to the media object, or null if it doesn't exist.
*/
async findById(id) {
if (activeRequests.has(id) && activeRequests.get(id).done) {
return activeRequests.get(id).mediaObject;
} else if (get(id)) {
return get(id);
} else {
const response = await axios.get(`${this._contentEndpoint}/${id}/meta`);
return response.status === 200 && put(new MediaObject(response.data));
}
}
/** Cancels the upload of a file if the upload is still in progress.
* @param {string} id Related content identifier to cancel the upload.
*/
cancelUpload(id) {
if (activeRequests.has(id)) {
activeRequests.get(id).cancel();
activeRequests.delete(id);
}
}
/** Starts an upload and returns the request.
* @param {File} file File to upload.
* @returns {MediaObject}
*/
upload(file) {
const id = generateId(file);
const existingRequest = activeRequests.get(id);
if (existingRequest && existingRequest.valid) {
return existingRequest.mediaObject;
} else if (existingRequest) {
existingRequest.revoke();
activeRequests.delete(id);
}
const asyncRequest = this._makeRequest(id, file);
const localMediaObject = put(
new MediaObject({
id,
name: file.name,
url: URL.createObjectURL(file),
type: file.type,
size: file.size
})
);
const activeRequest = new ActiveRequest({ localMediaObject, asyncRequest })
activeRequests.set(id, activeRequest);
return activeRequest.mediaObject;
}
_makeRequest(id, file) {
const data = new FormData();
const { name, type, size } = file;
data.set('name', name);
data.set('type', type);
data.set('size', size);
data.set('file', file);
const cancelToken = axios.CancelToken.source();
const innerRequest = axios.post(this._contentEndpoint, data, {
headers: {
'Content-Type': 'multipart/form-data'
},
cancelToken: cancelToken.token
});
return new AsyncRequest({ id, innerRequest, cancelToken });
}
}
import { MediaManager as MediaManagerClass } from './MediaManager';
const UPLOAD_ENDPOINT = '/content';
export const MediaManager = new MediaManagerClass(UPLOAD_ENDPOINT);
/** Represents any kind of media content.
*/
export class MediaObject {
constructor({ id, url, type, size }) {
constructor({ id, name, url, type, size }) {
this.id = id;
this.name = name;
this.url = url;
this.type = type;
this.size = size;
......
import axios from "axios";
import { v4 as uuid} from 'uuid';
async function wait(milliseconds) {
return new Promise((resolve => {
setTimeout(resolve, milliseconds);
}));
}
export const RequestStatus = {
PENDING: 'pending',
DONE: 'done',
CANCELLED: 'cancelled',
ERROR: 'error'
};
/** Represents an asynchronous HTTP request.
* It allows to wait for the request with a polling-like strategy using the #wait() method.
* Request can be cancelled using the #cancel() method.
*/
export class AsyncRequest {
/** Constructs a new async request to wrap an Axios request.
* @param {string} [id] Request id. If it's not specified it will be generated.
* @param {Promise<AxiosResponse<any> | void>} innerRequest Axios HTTP request.
* @param {CancelTokenSource} cancelToken Axios token to cancel the request.
*/
constructor({ id, innerRequest, cancelToken }) {
this.id = id || uuid();
this.status = RequestStatus.PENDING;
this._innerRequest = innerRequest;
this._cancelToken = cancelToken;
this.error = null;
this.result = null;
const asyncRequest = this;
innerRequest.then(result =>
asyncRequest._handleResult(result)
).catch(cause =>
asyncRequest._handleError(cause)
);
}
/** Adds a handler that's called only when the request ends successfully.
* @param {function(object)} callback Receives the Axios HTTP response.
* @returns {AsyncRequest} returns this request.
*/
onSuccess(callback) {
this._innerRequest.then(callback);
return this;
}
/** Wait for this request to finish.
*
* If the milliseconds parameter is equal to or less than 0, it will wait until the request ends,
* otherwise it will wait for the specified amount of milliseconds and then it will return.
*
* If this method is called after the request has ended, it will return true.
*
* @param {number} [milliseconds] Number of milliseconds to wait.
* @returns {Promise<boolean>} Returns true if the request has ended, false otherwise.
*/
async wait(milliseconds = 0) {
if (this.status !== RequestStatus.PENDING) {
return true;
}
if (milliseconds <= 0) {
await this.execSync();
return true;
} else {
await wait(milliseconds);
return this.status !== RequestStatus.PENDING;
}
}
/** Executes this request synchronously and returns the result.
* If the request is cancelled, it returns undefined.
* Any error is thrown synchronously.
* @returns {Promise<*>} returns the Axios HTTP response.
*/
async execSync() {
if (this.status !== RequestStatus.PENDING) {
throw new Error('Request already executed');
}
try {
return this._handleResult(await this._innerRequest);
} catch (cause) {
this._handleError(cause);
if (this.status === RequestStatus.ERROR) {
throw cause;
}
}
}
cancel() {
this._cancelToken.cancel();
}
_handleError(cause) {
if (axios.isCancel(cause)) {
this.status = RequestStatus.CANCELLED;
} else {
this.error = cause;
this.status = RequestStatus.ERROR;
}
}
_handleResult(result) {
this.result = result;
this.status = RequestStatus.DONE;
return result;
}
}
......@@ -41,6 +41,8 @@ class Endpoints(
// Security
before("/api/campaigns/new", securityProvider.jwt())
before("/session/attributes", securityProvider.jwt())
before("/content", securityProvider.jwt())
before("/content/:id/meta", securityProvider.jwt())
before("/campaigns/new", securityProvider.form())
// Controllers
......@@ -53,6 +55,8 @@ class Endpoints(
get("/session", runInTransaction(sessions::currentSession))
patch("/session/attributes", runInTransaction(sessions::setAttributes))
get("/content/:id", runInTransaction(documentController::read))