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:
46
src/scripts/js/es6/common/api/comments.js
Normal file
46
src/scripts/js/es6/common/api/comments.js
Normal 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 }
|
54
src/scripts/js/es6/common/api/files.js
Normal file
54
src/scripts/js/es6/common/api/files.js
Normal 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 }
|
17
src/scripts/js/es6/common/api/markdown.js
Normal file
17
src/scripts/js/es6/common/api/markdown.js
Normal 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 }
|
@@ -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 {
|
||||
|
@@ -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 };
|
@@ -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')
|
34
src/scripts/js/es6/common/utils/currentuser.js
Normal file
34
src/scripts/js/es6/common/utils/currentuser.js
Normal 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 }
|
@@ -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);
|
||||
}
|
||||
}
|
97
src/scripts/js/es6/common/utils/prettydate.js
Normal file
97
src/scripts/js/es6/common/utils/prettydate.js
Normal 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;
|
||||
}
|
@@ -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}`
|
||||
}
|
||||
}
|
||||
});
|
168
src/scripts/js/es6/common/vuecomponents/comments/Comment.js
Normal file
168
src/scripts/js/es6/common/vuecomponents/comments/Comment.js
Normal 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;
|
||||
},
|
||||
}
|
||||
});
|
@@ -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`;
|
||||
}
|
||||
}
|
||||
});
|
157
src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js
Normal file
157
src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
@@ -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';
|
||||
}
|
||||
},
|
||||
});
|
@@ -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();
|
52
src/scripts/js/es6/common/vuecomponents/comments/Rating.js
Normal file
52
src/scripts/js/es6/common/vuecomponents/comments/Rating.js
Normal 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')})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
@@ -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
|
||||
}
|
||||
},
|
||||
});
|
1
src/scripts/js/es6/common/vuecomponents/init.js
Normal file
1
src/scripts/js/es6/common/vuecomponents/init.js
Normal file
@@ -0,0 +1 @@
|
||||
import './comments/CommentTree'
|
86
src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js
Normal file
86
src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js
Normal 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 }
|
24
src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js
Normal file
24
src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js
Normal 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 }
|
@@ -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 }
|
12
src/scripts/js/es6/common/vuecomponents/user/Avatar.js
Normal file
12
src/scripts/js/es6/common/vuecomponents/user/Avatar.js
Normal 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},
|
||||
});
|
@@ -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,
|
||||
},
|
||||
});
|
@@ -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');
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
@@ -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)
|
||||
}
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user