<template> <div class="comment-drawer-component"> <input type="file" ref="fileInput" class="d-none" accept="image/jpeg,image/png" @change="handleImageUpload"> <div v-if="hide"></div> <div v-else-if="!isLoaded" class="border-top d-flex justify-content-center py-3"> <div class="text-center"> <div class="spinner-border text-lighter" role="status"> <span class="sr-only">Loading...</span> </div> <p class="text-muted">Loading Comments ...</p> </div> </div> <div v-else class="border-top"> <!-- <div v-if="profile && canReply" class="my-3"> <div class="d-flex align-items-top reply-form"> <img class="rounded-circle mr-2 border" :src="avatar" width="38" height="38"> <div v-if="isUploading" class="w-100"> <p class="font-weight-light mb-1">Uploading image ...</p> <div class="progress rounded-pill" style="height:4px"> <div class="progress-bar" role="progressbar" :aria-valuenow="uploadProgress" aria-valuemin="0" aria-valuemax="100" :style="{ width: uploadProgress + '%'}"></div> </div> </div> <div v-else class="reply-form-input"> <input class="form-control bg-light border-lighter rounded-pill" placeholder="Write a comment...." v-model="replyContent" v-on:keyup.enter="storeComment"> <div class="reply-form-input-actions"> <button class="btn btn-link text-muted px-1 mr-2" @click="uploadImage"> <i class="far fa-image fa-lg"></i> </button> <button class="btn btn-link text-muted px-1 small font-weight-bold border py-0 rounded-pill text-decoration-none"> GIF </button> </div> </div> </div> </div> --> <div class="my-3"> <div v-for="(status, index) in feed" :key="'cdf' + index + status.id" class="media media-status align-items-top"> <a v-if="replyChildId == status.id" href="#comment-1" class="comment-border-link" @click.prevent="replyToChild(status)"> <span class="sr-only">Jump to comment-{{ index }}</span> </a> <a :href="status.account.url"> <img class="rounded-circle media-avatar border" :src="status.account.avatar" width="32" height="32" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" /> </a> <div class="media-body"> <div v-if="!status.media_attachments.length" class="media-body-comment"> <p class="media-body-comment-username"> <a :href="status.account.url"> {{status.account.acct}} </a> </p> <read-more :status="status" /> </div> <div v-else> <p class="media-body-comment-username"> <a :href="status.account.url"> {{status.account.acct}} </a> </p> <div class="bh-comment" @click="lightbox(status)"> <blur-hash-image :width="blurhashWidth(status)" :height="blurhashHeight(status)" :punch="1" class="img-fluid rounded-lg border shadow" :hash="status.media_attachments[0].blurhash" :src="getMediaSource(status)" /> </div> </div> <p class="media-body-reactions"> <a v-if="profile" href="#" class="font-weight-bold" :class="[ status.favourited ? 'text-primary' : 'text-muted' ]" @click.prevent="likeComment(status, index, $event)"> {{ status.favourited ? 'Liked' : 'Like' }} </a> <span class="mx-1">·</span> <a href="#" class="text-muted font-weight-bold" @click.prevent="replyToChild(status, index)">Reply</a> <span v-if="profile" class="mx-1">·</span> <a class="font-weight-bold text-muted" :href="status.url" v-once> {{ shortTimestamp(status.created_at) }} </a> <span v-if="profile && status.account.id === profile.id"> <span class="mx-1">·</span> <a class="font-weight-bold text-lighter" href="#" @click.prevent="deleteComment(index)"> Delete </a> </span> </p> <!-- <div v-if="replyChildId == status.id && status.reply_count" class="media media-status align-items-top mt-3"> <div class="comment-border-arrow"></div> <a href="https://pixelfed.test/groups/328821658771132416/user/321493203255693312"><img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="32" height="32" class="rounded-circle media-avatar border"></a> <div class="media-body"><div class="media-body-comment"><p class="media-body-comment-username"><a href="https://pixelfed.test/groups/328821658771132416/user/321493203255693312"> dansup </a></p> <div class="read-more-component" style="word-break: break-all;"><div>test</div></div></div> <p class="media-body-reactions"><a href="#" class="font-weight-bold text-muted"> Like </a> <span class="mx-1">·</span> <a href="https://pixelfed.test/groups/328821658771132416/p/358529382599041029" class="font-weight-bold text-muted"> 1h </a> <span><span class="mx-1">·</span> <a href="#" class="font-weight-bold text-lighter"> Delete </a></span></p> </div> </div> --> <div v-if="replyChildIndex == index && status.hasOwnProperty('children') && status.children.hasOwnProperty('feed') && status.children.feed.length"> <comment-post v-for="(s, index) in status.children.feed" :status="s" :profile="profile" :commentBorderArrow="true" :key="'scp_'+index+'_'+s.id" /> </div> <a v-if="replyChildIndex == index && status.hasOwnProperty('children') && status.children.hasOwnProperty('can_load_more') && status.children.can_load_more == true" class="text-muted font-weight-bold mt-1 mb-0" style="font-size: 13px;" href="#" :disabled="loadingChildComments" @click.prevent="loadMoreChildComments(status, index)"> <div class="comment-border-arrow"></div> <i class="far fa-long-arrow-right mr-1"></i> {{ loadingChildComments ? 'Loading' : 'Load' }} more comments </a> <a v-else-if="replyChildIndex !== index && status.hasOwnProperty('children') && status.children.hasOwnProperty('can_load_more') && status.children.can_load_more == true && status.reply_count > 0 && !loadingChildComments" class="text-muted font-weight-bold mt-1 mb-0" style="font-size: 13px;" href="#" :disabled="loadingChildComments" @click.prevent="replyToChild(status, index)"> <i class="far fa-long-arrow-right mr-1"></i> {{ loadingChildComments ? 'Loading' : 'Load' }} more comments </a> <div v-if="replyChildId == status.id" class="mt-3 mb-3 d-flex align-items-top reply-form child-reply-form"> <div class="comment-border-arrow"></div> <img class="rounded-circle mr-2 border" :src="avatar" width="38" height="38" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"> <div v-if="isUploading" class="w-100"> <p class="font-weight-light mb-1">Uploading image ...</p> <div class="progress rounded-pill" style="height:10px"> <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" :aria-valuenow="uploadProgress" aria-valuemin="0" aria-valuemax="100" :style="{ width: uploadProgress + '%'}"></div> </div> </div> <div v-else class="reply-form-input"> <input class="form-control bg-light border-lighter rounded-pill" placeholder="Write a comment...." v-model="childReplyContent" :disabled="postingChildComment" v-on:keyup.enter="storeChildComment(index)"> <!-- <div class="reply-form-input-actions"> <button class="btn btn-link text-muted px-1 mr-2" @click="uploadImage"> <i class="far fa-image fa-lg"></i> </button> <button class="btn btn-link text-muted px-1 small font-weight-bold border py-0 rounded-pill text-decoration-none"> GIF </button> </div> --> </div> </div> </div> </div> <!-- <a v-if="permalinkMode && canLoadMore" class="text-muted mb-n1" style="font-size: 13px;font-weight: 600;" href="#">Load more comments ...</a> --> </div> <button v-if="canLoadMore" class="btn btn-link btn-sm text-muted mb-2" @click="loadMoreComments" :disabled="isLoadingMore"> <span v-if="!isLoadingMore"> Load more comments ... </span> <div v-else class="spinner-border spinner-border-sm text-muted" role="status"> <span class="sr-only">Loading...</span> </div> </button> <div v-if="profile && canReply" class="mt-3 mb-n3"> <div class="d-flex align-items-top reply-form cdrawer-reply-form"> <img class="rounded-circle mr-2 border" :src="avatar" width="38" height="38" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"> <div v-if="isUploading" class="w-100"> <p class="font-weight-light small text-muted mb-1">Uploading image ...</p> <div class="progress rounded-pill" style="height:10px"> <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" :aria-valuenow="uploadProgress" aria-valuemin="0" aria-valuemax="100" :style="{ width: uploadProgress + '%'}"></div> </div> </div> <div v-else class="w-100"> <div class="reply-form-input"> <textarea class="form-control bg-light border-lighter" placeholder="Write a comment...." :rows="replyContent && replyContent.length > 40 ? 4 : 1" v-model="replyContent"></textarea> <div class="reply-form-input-actions"> <button class="btn btn-link text-muted px-1 mr-2" @click="uploadImage"> <i class="far fa-image fa-lg"></i> </button> <!-- <button class="btn btn-link text-muted px-1 small font-weight-bold border py-0 rounded-pill text-decoration-none"> GIF </button> --> </div> </div> <div class="d-flex justify-content-between reply-form-menu"> <div class="char-counter"> <span>{{ replyContent?.length ?? 0 }}</span> <span>/</span> <span>500</span> </div> </div> </div> <button class="btn btn-link btn-sm font-weight-bold align-self-center ml-3 mb-3" @click="storeComment">Post</button> </div> </div> </div> <b-modal ref="lightboxModal" id="lightbox" :hide-header="true" :hide-footer="true" centered size="lg" body-class="p-0" content-class="bg-transparent border-0" > <div v-if="lightboxStatus" @click="hideLightbox"> <img :src="lightboxStatus.url" style="width: 100%;max-height: 90vh;object-fit: contain;"> </div> </b-modal> </div> </template> <script type="text/javascript"> import ReadMore from './ReadMore.vue'; import CommentPost from './CommentPost.vue'; export default { props: { groupId: { type: String }, profile: { type: Object }, status: { type: Object }, show: { type: Boolean, default: false }, permalinkMode: { type: Boolean, default: false }, permalinkStatus: { type: Object }, canReply: { type: Boolean, default: true } }, components: { "read-more": ReadMore, "comment-post": CommentPost }, data() { return { isLoaded: false, hide: false, feed: [], canLoadMore: false, isLoadingMore: false, replyContent: null, maxReplyId: null, readMoreCursor: 200, avatar: '/storage/avatars/default.png', isUploading: false, uploadProgress: 0, lightboxStatus: null, replyChildId: undefined, replyChildIndex: undefined, childReplyContent: null, postingChildComment: false, loadingChildComments: false, replyChildMinId: undefined, replyCursorId: null } }, mounted() { if(this.permalinkMode && this.permalinkStatus) { let status = this.permalinkStatus; if(status.reply_count) { status.children = { feed: [], can_load_more: true } } this.feed.push(status); this.isLoaded = true; this.canLoadMore = false; } else { this.fetchComments(); } if(this.profile && this.profile.hasOwnProperty('avatar')) { this.avatar = this.profile.avatar; } }, methods: { fetchComments() { axios.get('/api/v0/groups/comments', { params: { gid: this.groupId, sid: this.status.id, limit: 3 } }).then(res => { let data = res.data.map(function(c) { if(c.reply_count && c.reply_count > 0) { c.children = { feed: [], can_load_more: true } } return c; }) this.feed = data; this.isLoaded = true; this.maxReplyId = res.data[(res.data.length - 1)].id; if(this.feed.length == 3) { this.canLoadMore = true; } else { } }).catch(err => { this.isLoaded = true; }) }, loadMoreComments() { this.isLoadingMore = true; axios.get('/api/v0/groups/comments', { params: { gid: this.groupId, sid: this.status.id, limit: 3, max_id: this.maxReplyId } }).then(res => { if(res.data[res.data.length - 1].id == this.maxReplyId) { this.isLoadingMore = false; this.canLoadMore = false; return; } this.feed.push(...res.data); setTimeout(() => { this.isLoadingMore = false; }, 500); this.maxReplyId = res.data[res.data.length - 1].id; if(res.data.length > 0) { this.canLoadMore = true; } else { this.canLoadMore = false; } }).catch(err => { this.isLoadingMore = false; this.canLoadMore = false; }) }, storeComment($event) { $event.currentTarget?.blur(); axios.post('/api/v0/groups/comment', { gid: this.groupId, sid: this.status.id, content: this.replyContent }) .then(res => { this.replyContent = null; this.feed.unshift(res.data); }).catch(err => { if(err.response.status == 422) { this.isUploading = false; this.uploadProgress = 0; swal('Oops!', err.response.data.error, 'error'); } else { this.isUploading = false; this.uploadProgress = 0; swal('Oops!', 'An error occured while processing your request, please try again later', 'error'); } }) }, shortTimestamp(ts) { return window.App.util.format.timeAgo(ts); }, readMore() { this.readMoreCursor = this.readMoreCursor + 200; }, likeComment(status, index, $event) { $event.target.blur(); let l = status.favourited ? false : true; this.feed[index].favourited = l; status.favourited = l; axios.post(`/api/v0/groups/comment/${l ? 'like' : 'unlike'}`, { sid: status.id, gid: this.groupId }); }, deleteComment(index) { if(window.confirm('Are you sure you want to delete this post?') == false) { return; } axios.post('/api/v0/groups/comment/delete', { gid: this.groupId, id: this.feed[index].id }).then(res => { this.feed.splice(index, 1); }).catch(err => { console.log(err.response); swal('Error', 'Something went wrong. Please try again later.', 'error'); }); }, uploadImage() { this.$refs.fileInput.click(); }, handleImageUpload() { if(!this.$refs.fileInput.files.length) { return; } this.isUploading = true; let self = this; let data = new FormData(); data.append('gid', this.groupId); data.append('sid', this.status.id); data.append('photo', this.$refs.fileInput.files[0]); axios.post('/api/v0/groups/comment/photo', data, { onUploadProgress: function(progressEvent) { self.uploadProgress = Math.floor(progressEvent.loaded / progressEvent.total * 100); } }) .then(res => { this.isUploading = false; this.uploadProgress = 0; this.feed.unshift(res.data); }).catch(err => { if(err.response.status == 422) { this.isUploading = false; this.uploadProgress = 0; swal('Oops!', err.response.data.error, 'error'); } else { this.isUploading = false; this.uploadProgress = 0; swal('Oops!', 'An error occured while processing your request, please try again later', 'error'); } }); }, lightbox(status) { this.lightboxStatus = status.media_attachments[0]; this.$refs.lightboxModal.show(); }, hideLightbox() { this.lightboxStatus = null; this.$refs.lightboxModal.hide(); }, blurhashWidth(status) { if(!status.media_attachments[0].meta) { return 25; } let aspect = status.media_attachments[0].meta.original.aspect; if(aspect == 1) { return 25; } else if(aspect > 1) { return 30; } else { return 20; } }, blurhashHeight(status) { if(!status.media_attachments[0].meta) { return 25; } let aspect = status.media_attachments[0].meta.original.aspect; if(aspect == 1) { return 25; } else if(aspect > 1) { return 20; } else { return 30; } }, getMediaSource(status) { let media = status.media_attachments[0]; if(media.preview_url.endsWith('storage/no-preview.png')) { return media.url; } return media.preview_url; }, replyToChild(status, index) { if(this.replyChildId == status.id) { this.replyChildId = null; this.replyChildIndex = null; return; } else { this.childReplyContent = null; } this.replyChildId = status.id; this.replyCursorId = status.id this.replyChildIndex = index; if(!status.hasOwnProperty('replies_loaded') || !status.replies_loaded) { this.$nextTick(() => { this.fetchChildReplies(status, index); }); } else { this.$nextTick(() => { this.fetchChildReplies(status, index); }); } }, fetchChildReplies(status, index) { axios.get('/api/v0/groups/comments', { params: { gid: this.groupId, sid: status.id, cid: this.replyCursorId, limit: 3 } }).then(res => { if(this.feed[index].hasOwnProperty('children')) { this.feed[index].children.feed.push(res.data); this.feed[index].children.can_load_more = res.data.length == 3; } else { this.feed[index].children = { feed: res.data, can_load_more: res.data.length == 3 } } this.replyChildMinId = res.data[res.data.length - 1].id; this.$nextTick(() => { this.feed[index].replies_loaded = true; }); }).catch(err => { this.feed[index].children.can_load_more = false; }) }, storeChildComment(index) { this.postingChildComment = true; axios.post('/api/v0/groups/comment', { gid: this.groupId, sid: this.status.id, cid: this.replyChildId, content: this.childReplyContent }) .then(res => { this.childReplyContent = null; this.postingChildComment = false; this.feed[index].children.feed.push(res.data); // this.feed.unshift(res.data); }).catch(err => { if(err.response.status == 422) { // this.isUploading = false; // this.uploadProgress = 0; swal('Oops!', err.response.data.error, 'error'); } else { // this.isUploading = false; // this.uploadProgress = 0; swal('Oops!', 'An error occured while processing your request, please try again later', 'error'); } }); }, loadMoreChildComments(status, index) { this.loadingChildComments = true; axios.get('/api/v0/groups/comments', { params: { gid: this.groupId, sid: status.id, max_id: this.replyChildMinId, cid: 1, limit: 3 } }).then(res => { if(this.feed[index].hasOwnProperty('children')) { this.feed[index].children.feed.push(...res.data); this.feed[index].children.can_load_more = res.data.length == 3; } else { this.feed[index].children = { feed: res.data, can_load_more: res.data.length == 3 } } this.replyChildMinId = res.data[res.data.length - 1].id; this.feed[index].replies_loaded = true; this.loadingChildComments = false; }).catch(err => { }) } } } </script> <style lang="scss"> .comment-drawer-component { .media { position: relative; .comment-border-link { display: block; position: absolute; top: 40px; left: 11px; width: 10px; height: calc(100% - 100px); border-left: 4px solid transparent; border-right: 4px solid transparent; background-color: #E5E7EB; background-clip: padding-box; &:hover { background-color: #BFDBFE; } } .child-reply-form { position: relative; } .comment-border-arrow { display: block; position: absolute; top: -6px; left: -33px; width: 10px; height: 29px; border-left: 4px solid transparent; border-right: 4px solid transparent; background-color: #E5E7EB; background-clip: padding-box; border-bottom: 2px solid transparent; &:after { content: ''; display: block; position: absolute; top: 25px; left: 2px; width: 15px; height: 2px; background-color: #E5E7EB; } } &-status { margin-bottom: 1.3rem; } &-avatar { margin-right: 12px; } &-body { &-comment { width: fit-content; padding: 0.4rem 0.7rem; background-color: var(--comment-bg); border-radius: 0.9rem; &-username { margin-bottom: 0.25rem !important; font-size: 14px; font-weight: 700 !important; color: #000; a { color: #000; text-decoration: none; } } &-content { margin-bottom: 0; font-size: 16px; } } &-reactions { margin-top: 0.25rem !important; margin-bottom: 0 !important; color: #B8C2CC !important; font-size: 12px; } } } .load-more-comments { font-weight: 500; } .reply-form { margin-bottom: 2rem; &-input { flex: 1; position: relative; textarea { border-radius: 10px; } .form-control { resize: none; padding-right: 100px; } &-actions { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); } } .btn { text-decoration: none; } &-menu { margin-top: 5px; .char-counter { color: var(--muted); font-size: 10px; } } } .bh-comment { width: 100%; height: auto; max-width: 160px !important; max-height: 260px !important; span { width: 100%; height: auto; max-width: 160px !important; max-height: 260px !important; } img { width: 100%; height: auto; max-width: 160px !important; max-height: 260px !important; object-fit: cover; } } } </style>