Vue Comments: Comments ported to Vue + DnD fileupload

* Drag and drop files to comment editor to add a file attachment
* Using Vue to render comments

Since comments now has attachments we need to update the schemas
./manage.py maintenance replace_pillar_node_type_schemas
This commit is contained in:
2018-12-12 11:45:47 +01:00
parent bba1448acd
commit fbcce7a6d8
45 changed files with 2248 additions and 1477 deletions

View File

@@ -0,0 +1,46 @@
function thenGetComments(parentId) {
return $.getJSON(`/api/nodes/${parentId}/comments`);
}
function thenCreateComment(parentId, msg, attachments) {
let data = JSON.stringify({
msg: msg,
attachments: attachments
});
return $.ajax({
url: `/api/nodes/${parentId}/comments`,
type: 'POST',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
});
}
function thenUpdateComment(parentId, commentId, msg, attachments) {
let data = JSON.stringify({
msg: msg,
attachments: attachments
});
return $.ajax({
url: `/api/nodes/${parentId}/comments/${commentId}`,
type: 'PATCH',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
});
}
function thenVoteComment(parentId, commentId, vote) {
let data = JSON.stringify({
vote: vote
});
return $.ajax({
url: `/api/nodes/${parentId}/comments/${commentId}/vote`,
type: 'POST',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
});
}
export { thenGetComments, thenCreateComment, thenUpdateComment, thenVoteComment }

View File

@@ -0,0 +1,54 @@
function thenUploadFile(projectId, file, progressCB=(total, loaded)=>{}) {
let formData = createFormData(file)
return $.ajax({
url: `/api/storage/stream/${projectId}`,
type: 'POST',
data: formData,
cache: false,
contentType: false,
processData: false,
xhr: () => {
let myxhr = $.ajaxSettings.xhr();
if (myxhr.upload) {
// For handling the progress of the upload
myxhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
progressCB(e.total, e.loaded);
}
}, false);
}
return myxhr;
}
});
}
function createFormData(file) {
let formData = new FormData();
formData.append('file', file);
return formData;
}
function thenGetFileDocument(fileId) {
return $.get(`/api/files/${fileId}`);
}
function getFileVariation(fileDoc, size = 'm') {
var show_variation = null;
if (typeof fileDoc.variations != 'undefined') {
for (var variation of fileDoc.variations) {
if (variation.size != size) continue;
show_variation = variation;
break;
}
}
if (show_variation == null) {
throw 'Image not found: ' + fileDoc._id + ' size: ' + size;
}
return show_variation;
}
export { thenUploadFile, thenGetFileDocument, getFileVariation }

View File

@@ -0,0 +1,17 @@
function thenMarkdownToHtml(markdown, attachments={}) {
let data = JSON.stringify({
content: markdown,
attachments: attachments
});
return $.ajax({
url: "/nodes/preview-markdown",
type: 'POST',
headers: {"X-CSRFToken": csrf_token},
headers: {},
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
})
}
export { thenMarkdownToHtml }

View File

@@ -1,4 +1,5 @@
import { thenLoadImage, prettyDate } from '../utils';
import { prettyDate } from '../../utils/prettydate';
import { thenLoadImage } from '../utils';
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
export class NodesBase extends ComponentCreatorInterface {

View File

@@ -21,102 +21,4 @@ function thenLoadVideoProgress(nodeId) {
return $.get('/api/users/video/' + nodeId + '/progress')
}
function prettyDate(time, detail=false) {
/**
* time is anything Date can parse, and we return a
pretty string like 'an hour ago', 'Yesterday', '3 months ago',
'just now', etc
*/
let theDate = new Date(time);
if (!time || isNaN(theDate)) {
return
}
let pretty = '';
let now = new Date(Date.now()); // Easier to mock Date.now() in tests
let second_diff = Math.round((now - theDate) / 1000);
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
// "Jul 16, 2018"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
}
else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
}
else if (day_diff < -7){
let week_count = Math.round(-day_diff / 7);
if (week_count == 1)
pretty = "in 1 week";
else
pretty = "in " + week_count +" weeks";
}
else if (day_diff < -1)
// "next Tuesday"
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff === 0) {
if (second_diff < 0) {
let seconds = Math.abs(second_diff);
if (seconds < 10)
return 'just now';
if (seconds < 60)
return 'in ' + seconds +'s';
if (seconds < 120)
return 'in a minute';
if (seconds < 3600)
return 'in ' + Math.round(seconds / 60) + 'm';
if (seconds < 7200)
return 'in an hour';
if (seconds < 86400)
return 'in ' + Math.round(seconds / 3600) + 'h';
} else {
let seconds = second_diff;
if (seconds < 10)
return "just now";
if (seconds < 60)
return seconds + "s ago";
if (seconds < 120)
return "a minute ago";
if (seconds < 3600)
return Math.round(seconds / 60) + "m ago";
if (seconds < 7200)
return "an hour ago";
if (seconds < 86400)
return Math.round(seconds / 3600) + "h ago";
}
}
else if (day_diff == 1)
pretty = "yesterday";
else if (day_diff <= 7)
// "last Tuesday"
pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff <= 22) {
let week_count = Math.round(day_diff / 7);
if (week_count == 1)
pretty = "1 week ago";
else
pretty = week_count + " weeks ago";
}
else if (theDate.getFullYear() === now.getFullYear())
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
else
// "Jul 16", 2009
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
if (detail){
// "Tuesday at 04:20"
let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
return pretty + ' at ' + paddedHour + ':' + paddedMin;
}
return pretty;
}
export { thenLoadImage, thenLoadVideoProgress, prettyDate };
export { thenLoadImage, thenLoadVideoProgress };

View File

@@ -1,4 +1,4 @@
import { prettyDate } from '../utils'
import { prettyDate } from '../init'
describe('prettydate', () => {
beforeEach(() => {
@@ -28,7 +28,7 @@ describe('prettydate', () => {
expect(pd({minutes: -5, detailed: true})).toBe('5m ago')
expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46')
expect(pd({days: -8, detailed: true})).toBe('1 week ago at 11:46')
// summer time bellow
// summer time below
expect(pd({days: -14, detailed: true})).toBe('2 weeks ago at 10:46')
expect(pd({days: -31, detailed: true})).toBe('8 Oct at 10:46')
expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46')

View File

@@ -0,0 +1,34 @@
class User{
constructor(kwargs) {
this.user_id = kwargs['user_id'] || '';
this.username = kwargs['username'] || '';
this.full_name = kwargs['full_name'] || '';
this.gravatar = kwargs['gravatar'] || '';
this.email = kwargs['email'] || '';
this.capabilities = kwargs['capabilities'] || [];
this.badges_html = kwargs['badges_html'] || '';
this.is_authenticated = kwargs['is_authenticated'] || false;
}
/**
* """Returns True iff the user has one or more of the given capabilities."""
* @param {...String} args
*/
hasCap(...args) {
for(let cap of args) {
if (this.capabilities.indexOf(cap) != -1) return true;
}
return false;
}
}
let currentUser;
function initCurrentUser(kwargs){
currentUser = new User(kwargs);
}
function getCurrentUser() {
return currentUser;
}
export { getCurrentUser, initCurrentUser }

View File

@@ -1 +1,35 @@
export { transformPlaceholder } from './placeholder'
export { transformPlaceholder } from './placeholder'
export { prettyDate } from './prettydate'
export { getCurrentUser, initCurrentUser } from './currentuser'
export function debounced(fn, delay=1000) {
let timerId;
return function (...args) {
if (timerId) {
clearTimeout(timerId);
}
timerId = setTimeout(() => {
fn(...args);
timerId = null;
}, delay);
}
}
/**
* Extracts error message from error of type String, Error or xhrError
* @param {*} err
* @returns {String}
*/
export function messageFromError(err){
if (typeof err === "string") {
// type String
return err;
} else if(typeof err.message === "string") {
// type Error
return err.message;
} else {
// type xhr probably
return xhrErrorResponseMessage(err);
}
}

View File

@@ -0,0 +1,97 @@
export function prettyDate(time, detail=false) {
/**
* time is anything Date can parse, and we return a
pretty string like 'an hour ago', 'Yesterday', '3 months ago',
'just now', etc
*/
let theDate = new Date(time);
if (!time || isNaN(theDate)) {
return
}
let pretty = '';
let now = new Date(Date.now()); // Easier to mock Date.now() in tests
let second_diff = Math.round((now - theDate) / 1000);
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
// "Jul 16, 2018"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
}
else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
}
else if (day_diff < -7){
let week_count = Math.round(-day_diff / 7);
if (week_count == 1)
pretty = "in 1 week";
else
pretty = "in " + week_count +" weeks";
}
else if (day_diff < -1)
// "next Tuesday"
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff === 0) {
if (second_diff < 0) {
let seconds = Math.abs(second_diff);
if (seconds < 10)
return 'just now';
if (seconds < 60)
return 'in ' + seconds +'s';
if (seconds < 120)
return 'in a minute';
if (seconds < 3600)
return 'in ' + Math.round(seconds / 60) + 'm';
if (seconds < 7200)
return 'in an hour';
if (seconds < 86400)
return 'in ' + Math.round(seconds / 3600) + 'h';
} else {
let seconds = second_diff;
if (seconds < 10)
return "just now";
if (seconds < 60)
return seconds + "s ago";
if (seconds < 120)
return "a minute ago";
if (seconds < 3600)
return Math.round(seconds / 60) + "m ago";
if (seconds < 7200)
return "an hour ago";
if (seconds < 86400)
return Math.round(seconds / 3600) + "h ago";
}
}
else if (day_diff == 1)
pretty = "yesterday";
else if (day_diff <= 7)
// "last Tuesday"
pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff <= 22) {
let week_count = Math.round(day_diff / 7);
if (week_count == 1)
pretty = "1 week ago";
else
pretty = week_count + " weeks ago";
}
else if (theDate.getFullYear() === now.getFullYear())
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
else
// "Jul 16", 2009
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
if (detail){
// "Tuesday at 04:20"
let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
return pretty + ' at ' + paddedHour + ':' + paddedMin;
}
return pretty;
}

View File

@@ -0,0 +1,120 @@
import { thenGetFileDocument, getFileVariation } from '../../api/files'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
const VALID_NAME_REGEXP = /[a-zA-Z0-9_\-]+/g;
const NON_VALID_NAME_REGEXP = /[^a-zA-Z0-9_\-]+/g;
const TEMPLATE = `
<div class="attachment"
:class="{error: !isSlugOk}"
>
<div class="thumbnail-container"
@click="$emit('insert', oid)"
title="Click to add to comment"
>
<i :class="thumbnailBackup"
v-show="!thumbnail"
/>
<img class="preview-thumbnail"
v-if="!!thumbnail"
:src="thumbnail"
width=50
height=50
/>
</div>
<input class="form-control"
title="Slug"
v-model="newSlug"
/>
<div class="actions">
<div class="action delete"
@click="$emit('delete', oid)"
>
<i class="pi-trash"/>
Delete
</div>
</div>
</div>
`;
Vue.component('comment-attachment-editor', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {
slug: String,
allSlugs: Array,
oid: String
},
data() {
return {
newSlug: this.slug,
thumbnail: '',
thumbnailBackup: 'pi-spin spin',
}
},
computed: {
isValidAttachmentName() {
let regexpMatch = this.slug.match(VALID_NAME_REGEXP);
return !!regexpMatch && regexpMatch.length === 1 && regexpMatch[0] === this.slug;
},
isUnique() {
let countOccurrences = 0;
for (let s of this.allSlugs) {
// Don't worry about unicode. isValidAttachmentName denies those anyway
if (s.toUpperCase() === this.slug.toUpperCase()) {
countOccurrences++;
}
}
return countOccurrences === 1;
},
isSlugOk() {
return this.isValidAttachmentName && this.isUnique;
}
},
watch: {
newSlug(newValue, oldValue) {
this.$emit('rename', newValue, this.oid);
},
isSlugOk(newValue, oldValue) {
this.$emit('validation', this.oid, newValue);
}
},
created() {
this.newSlug = this.makeSafeAttachmentString(this.slug);
this.$emit('validation', this.oid, this.isSlugOk);
this.unitOfWork(
thenGetFileDocument(this.oid)
.then((fileDoc) => {
let content_type = fileDoc.content_type
if (content_type.startsWith('image')) {
try {
let imgFile = getFileVariation(fileDoc, 's');
this.thumbnail = imgFile.link;
} catch (error) {
this.thumbnailBackup = 'pi-image';
}
} else if(content_type.startsWith('video')) {
this.thumbnailBackup = 'pi-video';
} else {
this.thumbnailBackup = 'pi-file';
}
})
);
},
methods: {
/**
* Replaces all spaces with underscore and removes all o
* @param {String} unsafe
* @returns {String}
*/
makeSafeAttachmentString(unsafe) {
let candidate = (unsafe);
let matchSpace = / /g;
candidate = candidate
.replace(matchSpace, '_')
.replace(NON_VALID_NAME_REGEXP, '')
return candidate || `${this.oid}`
}
}
});

View File

@@ -0,0 +1,168 @@
import '../user/Avatar'
import '../utils/PrettyCreated'
import './CommentEditor'
import './Rating'
import { Linkable } from '../mixins/Linkable'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const TEMPLATE = `
<div class="comment-branch">
<div class="comment-container"
:class="{'is-first': !isReply, 'is-reply': isReply, 'comment-linked': isLinked}"
:id="comment.id">
<div class="comment-avatar">
<user-avatar
:user="comment.user"
/>
<div class="user-badges"
v-html="comment.user.badges_html">
</div>
</div>
<div class="comment-content">
<div class="comment-body"
v-if="!isUpdating"
>
<p class="comment-author">
{{ comment.user.full_name }}
</p>
<span class="comment-msg">
<p v-html="comment.msg_html"/>
</span>
</div>
<comment-editor
v-if="isUpdating"
@unit-of-work="childUnitOfWork"
:mode="editorMode"
:comment="comment"
:user="user"
:parentId="comment.id"
/>
<div class="comment-meta">
<comment-rating
:comment="comment"
@unit-of-work="childUnitOfWork"
/>
<div class="comment-action">
<span class="action" title="Reply to this comment"
v-if="canReply"
@click="showReplyEditor"
>
Reply
</span>
<span class="action" title="Edit comment"
v-if="canUpdate"
@click="showUpdateEditor"
>
Edit
</span>
<span class="action" title="Cancel changes"
v-if="canCancel"
@click="cancleEdit"
>
<i class="pi-cancel"></i>Cancel
</span>
</div>
<pretty-created
:created="comment.created"
:updated="comment.updated"
/>
</div>
</div>
</div>
<div class="comment-reply-container is-reply"
v-if="isReplying"
>
<user-avatar
:user="user"
/>
<comment-editor
v-if="isReplying"
@unit-of-work="childUnitOfWork"
:mode="editorMode"
:comment="comment"
:user="user"
:parentId="comment.id"
/>
</div>
<div class="comments-list">
<comment
v-for="c in comment.replies"
@unit-of-work="childUnitOfWork"
isReply=true
:readOnly="readOnly"
:comment="c"
:user="user"
:key="c.id"/>
</div>
</div>
`;
Vue.component('comment', {
template: TEMPLATE,
mixins: [Linkable, UnitOfWorkTracker],
props: {
user: Object,
comment: Object,
readOnly: {
type: Boolean,
default: false,
},
isReply: {
type: Boolean,
default: false,
},
},
data() {
return {
isReplying: false,
isUpdating: false,
id: this.comment.id,
}
},
computed: {
canUpdate() {
return !this.readOnly && this.comment.user.id === this.user.user_id && !this.isUpdating && !this.isReplying;
},
canReply() {
return !this.readOnly && !this.isUpdating && !this.isReplying;
},
canCancel() {
return this.isReplying || this.isUpdating;
},
editorMode() {
if(this.isReplying) {
return 'reply';
}
if(this.isUpdating) {
return 'update';
}
}
},
created() {
EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$on(Events.EDIT_DONE, this.doHideEditors);
},
beforeDestroy() {
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$off(Events.EDIT_DONE, this.doHideEditors);
},
methods: {
showReplyEditor() {
EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id );
this.isReplying = true;
},
showUpdateEditor() {
EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id );
this.isUpdating = true;
},
cancleEdit() {
this.doHideEditors();
EventBus.$emit(Events.EDIT_DONE, this.comment.id );
},
doHideEditors() {
this.isReplying = false;
this.isUpdating = false;
},
}
});

View File

@@ -0,0 +1,330 @@
import '../utils/MarkdownPreview'
import './AttachmentEditor'
import './UploadProgress'
import { thenCreateComment, thenUpdateComment } from '../../api/comments'
import { thenUploadFile } from '../../api/files'
import { Droptarget } from '../mixins/Droptarget'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const MAX_ATTACHMENTS = 5;
const TEMPLATE =`
<div class="comment-reply-form"
:class="dropTargetClasses"
>
<div class="attachments">
<comment-attachment-editor
v-for="a in attachments"
@delete="attachmentDelete"
@insert="insertAttachment"
@rename="attachmentRename"
@validation="attachmentValidation"
@unit-of-work="childUnitOfWork"
:slug="a.slug"
:allSlugs="allSlugs"
:oid="a.oid"
:key="a.oid"
/>
<upload-progress
v-if="uploads.nbrOfActive > 0"
:label="uploadProgressLabel"
:progress="uploadProgressPercent"
/>
</div>
<div class="comment-reply-field"
:class="{filled: isMsgLongEnough}"
>
<textarea
ref="inputField"
@keyup="keyUp"
v-model="msg"
id="comment_field"
placeholder="Join the conversation...">
</textarea>
<div class="comment-reply-meta">
<button class="comment-action-submit"
:class="{disabled: !canSubmit}"
@click="submit"
type="button"
title="Post Comment (Ctrl+Enter)">
<span>
<i :class="submitButtonIcon"/>{{ submitButtonText }}
</span>
<span class="hotkey">Ctrl + Enter</span>
</button>
</div>
</div>
<markdown-preview
v-show="msg.length > 0"
:markdown="msg"
:attachments="attachmentsAsObject"
/>
</div>
`;
Vue.component('comment-editor', {
template: TEMPLATE,
mixins: [Droptarget, UnitOfWorkTracker],
props: {
user: Object,
parentId: String,
projectId: String,
comment: Object,
mode: {
type: String,
default: 'reply', // reply or update
},
},
data() {
return {
msg: this.initialMsg(),
attachments: this.initialAttachments(),
uploads: {
nbrOfActive: 0,
nbrOfTotal: 0,
total: 0,
loaded: 0
},
}
},
computed: {
submitButtonText() {
switch(this.mode) {
case 'reply': return 'Send';
case 'update': return 'Update';
default: console.error('Unknown mode: ', this.mode);
}
},
submitButtonIcon() {
if (this.isBusyWorking) {
return 'pi-spin spin';
}else{
switch(this.mode) {
case 'reply': return 'pi-paper-plane';
case 'update': return 'pi-check';
default: console.error('Unknown mode: ', this.mode);
}
}
},
attachmentsAsObject() {
let attachmentsObject = {};
for (let a of this.attachments) {
attachmentsObject[a.slug] = {oid: a.oid};
}
return attachmentsObject;
},
allSlugs() {
return this.attachments.map((a) => {
return a['slug'];
});
},
isMsgLongEnough() {
return this.msg.length >= 5;
},
isAttachmentsValid() {
for (let att of this.attachments) {
if(!att.isSlugValid) {
return false;
}
}
return true;
},
isValid() {
return this.isAttachmentsValid && this.isMsgLongEnough;
},
canSubmit() {
return this.isValid && !this.isBusyWorking;
},
uploadProgressPercent() {
if (this.uploads.nbrOfActive === 0 || this.uploads.total === 0) {
return 100;
}
return this.uploads.loaded / this.uploads.total * 100;
},
uploadProgressLabel() {
if (this.uploadProgressPercent === 100) {
return 'Processing'
}
if (this.uploads.nbrOfTotal === 1) {
return 'Uploading file';
} else {
let fileOf = this.uploads.nbrOfTotal - this.uploads.nbrOfActive + 1;
return `Uploading ${fileOf}/${this.uploads.nbrOfTotal} files`;
}
},
},
watch:{
msg(){
this.autoSizeInputField();
}
},
mounted() {
if(this.comment) {
this.$nextTick(function () {
this.autoSizeInputField();
this.$refs.inputField.focus();
})
}
},
methods: {
initialMsg() {
if (this.comment) {
if (this.mode === 'reply') {
return `***@${this.comment.user.full_name}*** `;
}
if (this.mode === 'update') {
return this.comment.msg_markdown;
}
}
return '';
},
initialAttachments() {
// Transforming the attacmentobject to an array of attachments
let attachmentsList = []
if(this.mode === 'update') {
let attachmentsObj = this.comment.properties.attachments
for (let k in attachmentsObj) {
if (attachmentsObj.hasOwnProperty(k)) {
let a = {
slug: k,
oid: attachmentsObj[k]['oid'],
isSlugValid: true
}
attachmentsList.push(a);
}
}
}
return attachmentsList;
},
submit() {
if(!this.canSubmit) return;
this.unitOfWork(
this.thenSubmit()
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to submit comment')})
);
},
thenSubmit() {
if (this.mode === 'reply') {
return this.thenCreateComment();
} else {
return this.thenUpdateComment();
}
},
keyUp(e) {
if ((e.keyCode == 13 || e.key === 'Enter') && e.ctrlKey) {
this.submit();
}
},
thenCreateComment() {
return thenCreateComment(this.parentId, this.msg, this.attachmentsAsObject)
.then((newComment) => {
EventBus.$emit(Events.NEW_COMMENT, newComment);
EventBus.$emit(Events.EDIT_DONE, newComment.id );
this.cleanUp();
})
},
thenUpdateComment() {
return thenUpdateComment(this.comment.parent, this.comment.id, this.msg, this.attachmentsAsObject)
.then((updatedComment) => {
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
EventBus.$emit(Events.EDIT_DONE, updatedComment.id);
this.cleanUp();
})
},
canHandleDrop(event) {
let dataTransfer = event.dataTransfer;
let items = [...dataTransfer.items];
let nbrOfAttachments = items.length + this.uploads.nbrOfActive + this.attachments.length;
if(nbrOfAttachments > MAX_ATTACHMENTS) {
// Exceeds the limit
return false;
}
// Only files in drop
return [...dataTransfer.items].reduce((prev, it) => {
let isFile = it.kind === 'file' && !!it.type;
return prev && isFile;
}, !!items.length);
},
onDrop(event) {
let files = [...event.dataTransfer.files];
for (let f of files) {
this.unitOfWork(
this.thenUploadFile(f)
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'File upload failed')})
);
}
},
thenUploadFile(file){
let lastReportedTotal = 0;
let lastReportedLoaded = 0;
let progressCB = (total, loaded) => {
this.uploads.loaded += loaded - lastReportedLoaded;
this.uploads.total += total - lastReportedTotal;
lastReportedLoaded = loaded;
lastReportedTotal = total;
}
this.uploads.nbrOfActive++;
this.uploads.nbrOfTotal++;
return thenUploadFile(this.projectId || this.comment.project, file, progressCB)
.then((resp) => {
let attachment = {
slug: file.name,
oid: resp['file_id'],
isSlugValid: false
}
this.attachments.push(attachment);
this.msg += this.getAttachmentMarkdown(attachment);
})
.always(()=>{
this.uploads.nbrOfActive--;
if(this.uploads.nbrOfActive === 0) {
this.uploads.loaded = 0;
this.uploads.total = 0;
this.uploads.nbrOfTotal = 0;
}
})
},
getAttachmentMarkdown(attachment){
return `{attachment ${attachment.slug}}`;
},
insertAttachment(oid){
let attachment = this.getAttachment(oid);
this.msg += this.getAttachmentMarkdown(attachment);
},
attachmentDelete(oid) {
let attachment = this.getAttachment(oid);
let markdownToRemove = this.getAttachmentMarkdown(attachment);
this.msg = this.msg.replace(new RegExp(markdownToRemove,'g'), '');
this.attachments = this.attachments.filter((a) => {return a.oid !== oid});
},
attachmentRename(newName, oid) {
let attachment = this.getAttachment(oid);
let oldMarkdownAttachment = this.getAttachmentMarkdown(attachment);
attachment.slug = newName;
let newMarkdownAttachment = this.getAttachmentMarkdown(attachment);
this.msg = this.msg.replace(new RegExp(oldMarkdownAttachment,'g'), newMarkdownAttachment);
},
getAttachment(oid) {
for (let a of this.attachments) {
if (a.oid === oid) return a;
}
console.error('No attachment found:', oid);
},
attachmentValidation(oid, isValid) {
let attachment = this.getAttachment(oid);
attachment.isSlugValid = isValid;
},
cleanUp() {
this.msg = '';
this.attachments = [];
},
autoSizeInputField() {
let elInputField = this.$refs.inputField;
elInputField.style.cssText = 'height:auto; padding:0';
let newInputHeight = elInputField.scrollHeight + 20;
elInputField.style.cssText = `height:${ newInputHeight }px`;
}
}
});

View File

@@ -0,0 +1,157 @@
import './CommentEditor'
import './Comment'
import './CommentsLocked'
import '../user/Avatar'
import '../utils/GenericPlaceHolder'
import { thenGetComments } from '../../api/comments'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const TEMPLATE = `
<section class="comments-tree">
<div class="comment-reply-container"
v-if="canReply"
>
<user-avatar
:user="user"
/>
<comment-editor
v-if="canReply"
mode="reply"
@unit-of-work="childUnitOfWork"
:projectId="projectId"
:parentId="parentId"
:user="user"
/>
</div>
<comments-locked
v-if="readOnly||!isLoggedIn"
:user="user"
/>
<div class="comments-list-title">{{ numberOfCommentsStr }}</div>
<div class="comments-list">
<comment
v-for="c in comments"
@unit-of-work="childUnitOfWork"
:readOnly=readOnly||!isLoggedIn
:comment="c"
:user="user"
:key="c.id"/>
</div>
<generic-placeholder
v-show="showLoadingPlaceholder"
label="Loading Comments..."
/>
</section>
`;
Vue.component('comments-tree', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {
parentId: String,
readOnly: {
type: Boolean,
default: false
}
},
data() {
return {
replyHidden: false,
nbrOfComments: 0,
projectId: '',
comments: [],
showLoadingPlaceholder: true,
user: pillar.utils.getCurrentUser(),
canPostComments: this.canPostCommentsStr == 'true'
}
},
computed: {
numberOfCommentsStr() {
let pluralized = this.nbrOfComments === 1 ? 'Comment' : 'Comments'
return `${ this.nbrOfComments } ${ pluralized }`;
},
isLoggedIn() {
return this.user.is_authenticated;
},
iSubscriber() {
return this.user.hasCap('subscriber');
},
canRenewSubscription() {
return this.user.hasCap('can-renew-subscription');
},
canReply() {
return !this.readOnly && !this.replyHidden && this.isLoggedIn;
}
},
watch: {
isBusyWorking(isBusy) {
if(isBusy) {
$(document).trigger('pillar:workStart');
} else {
$(document).trigger('pillar:workStop');
}
}
},
created() {
EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$on(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$on(Events.NEW_COMMENT, this.onNewComment);
EventBus.$on(Events.UPDATED_COMMENT, this.onCommentUpdated);
this.unitOfWork(
thenGetComments(this.parentId)
.then((commentsTree) => {
this.nbrOfComments = commentsTree['nbr_of_comments'];
this.comments = commentsTree['comments'];
this.projectId = commentsTree['project'];
})
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to load comments')})
.always(()=>this.showLoadingPlaceholder = false)
);
},
beforeDestroy() {
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$off(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$off(Events.NEW_COMMENT, this.onNewComment);
EventBus.$off(Events.UPDATED_COMMENT, this.onCommentUpdated);
},
methods: {
doHideEditors() {
this.replyHidden = true;
},
showReplyComponent() {
this.replyHidden = false;
},
onNewComment(newComment) {
this.nbrOfComments++;
let parentArray;
if(newComment.parent === this.parentId) {
parentArray = this.comments;
} else {
let parentComment = this.findComment(this.comments, (comment) => {
return comment.id === newComment.parent;
});
parentArray = parentComment.replies;
}
parentArray.unshift(newComment);
},
onCommentUpdated(updatedComment) {
let commentInTree = this.findComment(this.comments, (comment) => {
return comment.id === updatedComment.id;
});
delete updatedComment.replies; // No need to apply these since they should be the same
Object.assign(commentInTree, updatedComment);
},
findComment(arrayOfComments, matcherCB) {
for(let comment of arrayOfComments) {
if(matcherCB(comment)) {
return comment;
}
let match = this.findComment(comment.replies, matcherCB);
if (match) {
return match;
}
}
}
},
});

View File

@@ -0,0 +1,53 @@
const TEMPLATE = `
<div class="comments-locked">
<div
v-if="msgToShow === 'PROJECT_MEMBERS_ONLY'"
>
<i class="pi-lock"/>
Only project members can comment.
</div>
<div
v-if="msgToShow === 'RENEW'"
>
<i class="pi-heart"/>
Join the conversation!
<a href="/renew" target="_blank"> Renew your subscription </a>
to comment.
</div>
<div
v-if="msgToShow === 'JOIN'"
>
<i class="pi-heart"/>
Join the conversation!
<a href="https://store.blender.org/product/membership/" target="_blank"> Subscribe to Blender Cloud </a>
to comment.
</div>
<div
v-if="msgToShow === 'LOGIN'"
>
<a href="/login"> Log in to comment</a>
</div>
</div>
`;
Vue.component('comments-locked', {
template: TEMPLATE,
props: {user: Object},
computed: {
msgToShow() {
if(this.user && this.user.is_authenticated) {
if (this.user.hasCap('subscriber')) {
return 'PROJECT_MEMBERS_ONLY';
} else if(this.user.hasCap('can-renew-subscription')) {
return 'RENEW';
} else {
return 'JOIN';
}
}
return 'LOGIN';
}
},
});

View File

@@ -0,0 +1,7 @@
export const Events = {
NEW_COMMENT: 'new-comment',
UPDATED_COMMENT: 'updated-comment',
EDIT_DONE: 'edit-done',
BEFORE_SHOW_EDITOR: 'before-show-editor'
}
export const EventBus = new Vue();

View File

@@ -0,0 +1,52 @@
import { EventBus, Events } from './EventBus'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { thenVoteComment } from '../../api/comments'
const TEMPLATE = `
<div class="comment-rating"
:class="{rated: hasRating, positive: isPositive }"
>
<div class="comment-rating-value" title="Number of likes">{{ rating }}</div>
<div class="comment-action-rating up" title="Like comment"
v-if="canVote"
@click="upVote"
/>
</div>
`;
Vue.component('comment-rating', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {comment: Object},
computed: {
positiveRating() {
return this.comment.properties.rating_positive || 0;
},
negativeRating() {
return this.comment.properties.rating_negative || 0;
},
rating() {
return this.positiveRating - this.negativeRating;
},
isPositive() {
return this.rating > 0;
},
hasRating() {
return (this.positiveRating || this.negativeRating) !== 0;
},
canVote() {
return this.comment.user.id !== pillar.utils.getCurrentUser().user_id;
}
},
methods: {
upVote() {
let vote = this.comment.current_user_rating === true ? 0 : 1; // revoke if set
this.unitOfWork(
thenVoteComment(this.comment.parent, this.comment.id, vote)
.then((updatedComment) => {
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
})
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Faied to vote on comment')})
);
}
}
});

View File

@@ -0,0 +1,23 @@
const TEMPLATE = `
<div class="upload-progress">
<label>
{{ label }}
</label>
<progress class="progress-uploading"
max="100"
:value="progress"
>
</progress>
</div>
`;
Vue.component('upload-progress', {
template: TEMPLATE,
props: {
label: String,
progress: {
type: Number,
default: 0
}
},
});

View File

@@ -0,0 +1 @@
import './comments/CommentTree'

View File

@@ -0,0 +1,86 @@
/**
* Vue mixin that makes the component a droptarget
* override canHandleDrop(event) and onDrop(event)
* dragOverClasses can be bound to target class
*/
var Droptarget = {
data() {
return {
droptargetCounter: 0,
droptargetCanHandle: false
}
},
computed: {
isDragingOver() {
return this.droptargetCounter > 0;
},
dropTargetClasses() {
return {
'drag-hover': this.isDragingOver,
'unsupported-drop': this.isDragingOver && !this.droptargetCanHandle
}
}
},
mounted() {
this.$nextTick(function () {
this.$el.addEventListener('dragenter', this._onDragEnter);
this.$el.addEventListener('dragleave', this._onDragLeave);
this.$el.addEventListener('dragend', this._onDragEnd);
this.$el.addEventListener('dragover', this._onDragOver);
this.$el.addEventListener('drop', this._onDrop);
});
},
beforeDestroy() {
this.$el.removeEventListener('dragenter', this._onDragEnter);
this.$el.removeEventListener('dragleave', this._onDragLeave);
this.$el.removeEventListener('dragend', this._onDragEnd);
this.$el.removeEventListener('dragover', this._onDragOver);
this.$el.removeEventListener('drop', this._onDrop);
},
methods: {
canHandleDrop(event) {
throw Error('Not implemented');
},
onDrop(event) {
throw Error('Not implemented');
},
_onDragEnter(event) {
event.preventDefault();
event.stopPropagation();
this.droptargetCounter++;
if(this.droptargetCounter === 1) {
try {
this.droptargetCanHandle = this.canHandleDrop(event);
} catch (error) {
console.warn(error);
this.droptargetCanHandle = false;
}
}
},
_onDragLeave() {
this.droptargetCounter--;
},
_onDragEnd() {
this.droptargetCounter = 0;
},
_onDragOver() {
event.preventDefault();
event.stopPropagation();
},
_onDrop(event) {
event.preventDefault();
event.stopPropagation();
if(this.droptargetCanHandle) {
try {
this.onDrop(event);
} catch (error) {
console.console.warn(error);
}
}
this.droptargetCounter = 0;
},
}
}
export { Droptarget }

View File

@@ -0,0 +1,24 @@
/**
* Vue mixin that scrolls element into view if id matches #value in url
* @param {String} id identifier that is set by the user of the mixin
* @param {Boolean} isLinked true if Component is linked
*/
let hash = window.location.hash.substr(1).split('?')[0];
var Linkable = {
data() {
return {
id: '',
isLinked: false,
}
},
mounted: function () {
this.$nextTick(function () {
if(hash && this.id === hash) {
this.isLinked = true;
this.$el.scrollIntoView({ behavior: 'smooth' });
}
})
}
}
export { Linkable }

View File

@@ -0,0 +1,59 @@
/**
* Vue helper mixin to keep track if work is in progress or not.
* Example use:
* Keep track of work in own component:
* this.unitOfWork(
* thenDostuff()
* .then(...)
* .fail(...)
* );
*
* Keep track of work in child components:
* <myChild
* @unit-of-work="childUnitOfWork"
* />
*
* Use the information to enable class:
* <div :class="{disabled: 'isBusyWorking'}">
*/
var UnitOfWorkTracker = {
data() {
return {
unitOfWorkCounter: 0,
}
},
computed: {
isBusyWorking() {
if(this.unitOfWorkCounter < 0) {
console.error('UnitOfWork missmatch!')
}
return this.unitOfWorkCounter > 0;
}
},
watch: {
isBusyWorking(isBusy) {
if(isBusy) {
this.$emit('unit-of-work', 1);
} else {
this.$emit('unit-of-work', -1);
}
}
},
methods: {
unitOfWork(promise) {
this.unitOfWorkBegin();
return promise.always(this.unitOfWorkDone);
},
unitOfWorkBegin() {
this.unitOfWorkCounter++;
},
unitOfWorkDone() {
this.unitOfWorkCounter--;
},
childUnitOfWork(direction) {
this.unitOfWorkCounter += direction;
}
}
}
export { UnitOfWorkTracker }

View File

@@ -0,0 +1,12 @@
const TEMPLATE = `
<div class="user-avatar">
<img
:src="user.gravatar"
:alt="user.full_name">
</div>
`;
Vue.component('user-avatar', {
template: TEMPLATE,
props: {user: Object},
});

View File

@@ -0,0 +1,13 @@
const TEMPLATE =
`<div class="generic-placeholder" :title="label">
<i class="pi-spin spin"/>
{{ label }}
</div>
`;
Vue.component('generic-placeholder', {
template: TEMPLATE,
props: {
label: String,
},
});

View File

@@ -0,0 +1,56 @@
import { debounced } from '../../utils/init'
import { thenMarkdownToHtml } from '../../api/markdown'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
const TEMPLATE = `
<div class="markdown-preview">
<div class="markdown-preview-md"
v-html="asHtml"/>
<div class="markdown-preview-info">
<a
title="Handy guide of Markdown syntax"
target="_blank"
href="http://commonmark.org/help/">
<span>markdown cheatsheet</span>
</a>
</div>
</div>
`;
Vue.component('markdown-preview', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {
markdown: String,
attachments: Object
},
data() {
return {
asHtml: '',
}
},
created() {
this.markdownToHtml(this.markdown, this.attachments);
this.debouncedMarkdownToHtml = debounced(this.markdownToHtml);
},
watch: {
markdown(newValue, oldValue) {
this.debouncedMarkdownToHtml(newValue, this.attachments);
},
attachments(newValue, oldValue) {
this.debouncedMarkdownToHtml(this.markdown, newValue);
}
},
methods: {
markdownToHtml(markdown, attachments) {
this.unitOfWork(
thenMarkdownToHtml(markdown, attachments)
.then((data) => {
this.asHtml = data.content;
})
.fail((err) => {
toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
})
);
}
}
});

View File

@@ -0,0 +1,33 @@
import { prettyDate } from '../../utils/init'
const TEMPLATE =
`<div class="pretty-created" :title="'Posted ' + created">
{{ prettyCreated }}
<span
v-if="isEdited"
:title="'Updated ' + prettyUpdated"
>*</span>
</div>
`;
Vue.component('pretty-created', {
template: TEMPLATE,
props: {
created: String,
updated: String,
detailed: {
type: Boolean,
default: true
}
},
computed: {
prettyCreated() {
return prettyDate(this.created, this.detailed);
},
prettyUpdated() {
return prettyDate(this.updated, this.detailed);
},
isEdited() {
return this.updated && (this.created !== this.updated)
}
}
});