Browse Source

Add partial components

Daniel Supernault 2 years ago
parent
commit
5361082026
21 changed files with 5715 additions and 0 deletions
  1. 295 0
      resources/assets/components/partials/direct/Message.vue
  2. 83 0
      resources/assets/components/partials/discover/daily-trending.vue
  3. 162 0
      resources/assets/components/partials/discover/grid-card.vue
  4. 106 0
      resources/assets/components/partials/drawer.vue
  5. 187 0
      resources/assets/components/partials/modal/ReportPost.vue
  6. 148 0
      resources/assets/components/partials/modal/UpdateAvatar.vue
  7. 978 0
      resources/assets/components/partials/navbar.vue
  8. 10 0
      resources/assets/components/partials/placeholders/DirectMessagePlaceholder.vue
  9. 6 0
      resources/assets/components/partials/placeholders/EmptyTimeline.vue
  10. 30 0
      resources/assets/components/partials/placeholders/NotificationPlaceholder.vue
  11. 105 0
      resources/assets/components/partials/placeholders/TimelineOnboarding.vue
  12. 1066 0
      resources/assets/components/partials/post/CommentDrawer.vue
  13. 470 0
      resources/assets/components/partials/post/CommentReplies.vue
  14. 64 0
      resources/assets/components/partials/post/CommentReplyForm.vue
  15. 803 0
      resources/assets/components/partials/post/ContextMenu.vue
  16. 31 0
      resources/assets/components/partials/post/EditHistoryModal.vue
  17. 15 0
      resources/assets/components/partials/post/LikeListPlaceholder.vue
  18. 243 0
      resources/assets/components/partials/post/MediaContainer.vue
  19. 222 0
      resources/assets/components/partials/post/PostContent.vue
  20. 592 0
      resources/assets/components/partials/post/PostEditModal.vue
  21. 99 0
      resources/assets/components/partials/post/ReadMore.vue

+ 295 - 0
resources/assets/components/partials/direct/Message.vue

@@ -0,0 +1,295 @@
+<template>
+    <div class="dm-chat-message chat-msg">
+        <div
+            class="media d-inline-flex mb-0"
+            :class="{ isAuthor: convo.isAuthor }"
+            >
+            <img v-if="!convo.isAuthor && !hideAvatars" class="mr-3 shadow msg-avatar" :src="thread.avatar" alt="avatar" width="50" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
+
+            <div class="media-body">
+                <p v-if="convo.type == 'photo'" class="pill-to p-0 shadow">
+                    <img
+                        :src="convo.media"
+                        class="media-embed"
+                        style="cursor: pointer;"
+                        onerror="this.onerror=null;this.src='/storage/no-preview.png';"
+                        @click.prevent="expandMedia">
+                </p>
+                <div v-else-if="convo.type == 'link'" class="d-inline-flex mb-0 cursor-pointer">
+                    <div class="card shadow border" style="width:240px;border-radius: 18px;">
+                        <div class="card-body p-0" :title="convo.text">
+                                <div class="media align-items-center">
+                                    <div v-if="convo.meta.local" class="bg-primary mr-3 p-3" style="border-radius: 18px;">
+                                        <i class="fas fa-link text-white fa-2x"></i>
+                                    </div>
+                                    <div v-else class="bg-light mr-3 p-3" style="border-radius: 18px;">
+                                        <i class="fas fa-link text-lighter fa-2x"></i>
+                                    </div>
+                                    <div class="media-body text-muted small text-truncate pr-2 font-weight-bold">
+                                        {{convo.meta.local ? convo.text.substr(8) : convo.meta.domain}}
+                                    </div>
+                                </div>
+                        </div>
+                    </div>
+                </div>
+                <p v-else-if="convo.type == 'video'" class="pill-to p-0 shadow mb-0" style="line-height: 0;">
+                    <video :src="convo.media" class="media-embed" style="border-radius:20px;" controls>
+                    </video>
+                    <!-- <span class="d-block bg-primary d-flex align-items-center justify-content-center" style="width:200px;height: 110px;border-radius: 20px;">
+                        <div class="text-center">
+                            <p class="mb-1">
+                                <i class="fas fa-play fa-2x text-white"></i>
+                            </p>
+                            <p class="mb-0 small font-weight-bold text-white">
+                                Play
+                            </p>
+                        </div>
+                    </span> -->
+                </p>
+                <p v-else-if="convo.type == 'emoji'" class="p-0 emoji-msg">
+                    {{convo.text}}
+                </p>
+                <p v-else-if="convo.type == 'story:react'" class="pill-to p-0 shadow" style="width: 140px;margin-bottom: 10px;position:relative;">
+                    <img :src="convo.meta.story_media_url" width="140" style="border-radius:20px;" onerror="this.onerror=null;this.src='/storage/no-preview.png';">
+                    <span class="badge badge-light rounded-pill border" style="font-size: 20px;position: absolute;bottom:-10px;left:-10px;">
+                        {{convo.meta.reaction}}
+                    </span>
+                </p>
+                <span v-else-if="convo.type == 'story:comment'" class="p-0" style="display: flex;justify-content: flex-start;margin-bottom: 10px;position:relative;">
+                    <span class="">
+                        <img class="d-block pill-to p-0 mr-0 pr-0 mb-n1" :src="convo.meta.story_media_url" width="140" style="border-radius:20px;" onerror="this.onerror=null;this.src='/storage/no-preview.png';">
+                        <span class="pill-to shadow text-break" style="width:fit-content;">{{convo.meta.caption}}</span>
+                    </span>
+                </span>
+                <p v-else :class="[largerText ? 'pill-to shadow larger-text text-break':'pill-to shadow text-break']">
+                    {{convo.text}}
+                </p>
+                <p v-if="convo.type == 'story:react'" class="small text-muted mb-0 ml-0">
+                    <span class="font-weight-bold">{{ convo.meta.story_actor_username }}</span> reacted your story
+                </p>
+                <p v-if="convo.type == 'story:comment'" class="small text-muted mb-0 ml-0">
+                    <span class="font-weight-bold">{{ convo.meta.story_actor_username }}</span> replied to your story
+                </p>
+
+                <p
+                    class="msg-timestamp small text-muted font-weight-bold d-flex align-items-center justify-content-start"
+                    data-timestamp="timestamp">
+                    <span
+                        v-if="convo.hidden"
+                        class="small pr-2"
+                        title="Filtered Message"
+                        data-toggle="tooltip"
+                        data-placement="bottom">
+                        <i class="fas fa-lock"></i>
+                    </span>
+
+                    <span v-if="!hideTimestamps">
+                        {{convo.timeAgo}}
+                    </span>
+
+                    <button
+                        v-if="convo.isAuthor"
+                        class="btn btn-link btn-sm text-lighter pl-2 font-weight-bold"
+                        @click="confirmDelete">
+                        <i class="far fa-trash-alt"></i>
+                    </button>
+                </p>
+            </div>
+
+            <img v-if="convo.isAuthor && !hideAvatars" class="ml-3 shadow msg-avatar" :src="profile.avatar" alt="avatar" width="50" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import BigPicture from 'bigpicture';
+
+    export default {
+        props: {
+            thread: {
+                type: Object
+            },
+
+            convo: {
+                type: Object
+            },
+
+            hideAvatars: {
+                type: Boolean,
+                default: false
+            },
+
+            hideTimestamps: {
+                type: Boolean,
+                default: false
+            },
+
+            largerText: {
+                type: Boolean,
+                default: false
+            }
+        },
+
+        data() {
+            return {
+                profile: window._sharedData.user
+            }
+        },
+
+        methods: {
+            truncate(t) {
+                return _.truncate(t);
+            },
+
+            viewOriginal() {
+                let url = this.ctxContext.media;
+                window.location.href = url;
+                return;
+            },
+
+            isEmoji(text) {
+                const onlyEmojis = text.replace(new RegExp('[\u0000-\u1eeff]', 'g'), '')
+                const visibleChars = text.replace(new RegExp('[\n\r\s]+|( )+', 'g'), '')
+                return onlyEmojis.length === visibleChars.length
+            },
+
+            copyText() {
+                window.App.util.clipboard(this.ctxContext.text);
+                this.closeCtxMenu();
+                return;
+            },
+
+            clickLink() {
+                let url = this.ctxContext.text;
+                if(this.ctxContext.meta.local != true) {
+                    url = '/i/redirect?url=' + encodeURI(this.ctxContext.text);
+                }
+                window.location.href = url;
+            },
+
+            formatCount(val) {
+                return window.App.util.format.count(val);
+            },
+
+            confirmDelete() {
+                this.$emit('confirm-delete');
+            },
+
+            expandMedia(e) {
+                BigPicture({
+                    el: e.target
+                })
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .chat-msg {
+        padding-top: 0;
+        padding-bottom: 0;
+    }
+
+    .reply-btn {
+        position: absolute;
+        bottom: 54px;
+        right: 20px;
+        width: 90px;
+        text-align: center;
+        border-radius: 0 3px 3px 0;
+    }
+
+    .media-body .bg-primary {
+        background: linear-gradient(135deg, #2EA2F4 0%, #0B93F6 100%) !important;
+    }
+
+    .pill-to {
+        background: var(--bg-light);
+        font-weight: 500;
+        border-radius: 20px !important;
+        padding-left: 1rem;
+        padding-right: 1rem;
+        padding-top: 0.5rem;
+        padding-bottom: 0.5rem;
+        margin-right: 3rem;
+        margin-bottom: 0.25rem;
+    }
+    .pill-from {
+        color: white !important;
+        text-align: right !important;
+        background: linear-gradient(135deg, #2EA2F4 0%, #0B93F6 100%) !important;
+        font-weight: 500;
+        border-radius: 20px !important;
+        padding-left: 1rem;
+        padding-right: 1rem;
+        padding-top: 0.5rem;
+        padding-bottom: 0.5rem;
+        margin-left: 3rem;
+        margin-bottom: 0.25rem;
+    }
+    .chat-smsg:hover {
+        background: var(--light-hover-bg);
+    }
+    .no-focus {
+        border: none !important;
+    }
+    .no-focus:focus {
+        outline: none !important;
+        outline-width: 0 !important;
+        box-shadow: none;
+        -moz-box-shadow: none;
+        -webkit-box-shadow: none;
+    }
+    .emoji-msg {
+        font-size: 4rem !important;
+        line-height: 30px !important;
+        margin-top: 10px !important;
+    }
+    .larger-text {
+        font-size: 22px;
+    }
+
+    .dm-chat-message {
+
+        .isAuthor {
+            float: right;
+            margin-right: 0.5rem !important;
+
+            .pill-to {
+                color: white !important;
+                text-align: right !important;
+                background: linear-gradient(135deg, #2EA2F4 0%, #0B93F6 100%) !important;
+                font-weight: 500;
+                border-radius: 20px !important;
+                padding-left: 1rem;
+                padding-right: 1rem;
+                padding-top: 0.5rem;
+                padding-bottom: 0.5rem;
+                margin-left: 3rem;
+                margin-right: 0;
+                margin-bottom: 0.25rem;
+            }
+
+            .msg-timestamp {
+                display: block !important;
+                text-align: right;
+                margin-bottom: 0;
+            }
+        }
+
+        .msg-avatar {
+            width: 50px;
+            height: 50px;
+            border-radius: 14px;
+        }
+
+        .media-embed {
+            width: 140px;
+            border-radius: 20px;
+
+            @media (min-width: 450px) {
+                width: 200px;
+            }
+        }
+    }
+</style>

+ 83 - 0
resources/assets/components/partials/discover/daily-trending.vue

@@ -0,0 +1,83 @@
+<template>
+	<div class="discover-daily-trending">
+		<div class="card bg-stellar">
+			<div class="card-body m-5">
+				<div class="row d-flex align-items-center">
+					<div class="col-12 col-md-5">
+						<p class="font-default text-light mb-0">Popular and trending posts</p>
+						<h1 class="display-4 font-default text-white" style="font-weight: 700;">Daily Trending</h1>
+						<button class="btn btn-outline-light rounded-pill" @click="viewMore()">View more trending posts</button>
+					</div>
+					<div class="col-12 col-md-7">
+						<div v-if="isLoaded" class="row">
+							<div v-for="(post, index) in trending" class="col-4">
+								<a :href="post.url" @click.prevent="gotoPost(post.id)">
+									<img :src="post.media_attachments[0].url" class="shadow m-1" width="170" height="170" style="object-fit: cover;border-radius:8px">
+								</a>
+							</div>
+						</div>
+						<div v-else class="row">
+							<div class="col-12 d-flex justify-content-center">
+								<b-spinner type="grow" variant="light" />
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		data() {
+			return {
+				isLoaded: false,
+				initialFetch: false,
+				trending: []
+			}
+		},
+
+		mounted() {
+			if(!this.initialFetch) {
+				this.fetchTrending();
+			}
+		},
+		methods: {
+			fetchTrending() {
+				axios.get('/api/pixelfed/v2/discover/posts/trending', {
+					params: {
+						range: 'daily'
+					}
+				})
+				.then(res => {
+					this.trending = res.data.filter(p => p.pf_type === 'photo').slice(0, 9);
+					this.isLoaded = true;
+					this.initialFetch = true;
+				});
+			},
+
+			gotoPost(id) {
+				this.$router.push('/i/web/post/' + id);
+			},
+
+			viewMore() {
+				this.$emit('btn-click', 'trending');
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.discover-daily-trending {
+		.bg-stellar {
+			background: #7474BF;
+			background: -webkit-linear-gradient(to right, #348AC7, #7474BF);
+			background: linear-gradient(to right, #348AC7, #7474BF);
+		}
+		.font-default {
+			font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+			letter-spacing: -0.7px;
+		}
+	}
+</style>

+ 162 - 0
resources/assets/components/partials/discover/grid-card.vue

@@ -0,0 +1,162 @@
+<template>
+	<div class="discover-grid-card">
+		<div
+			class="discover-grid-card-body"
+			:class="{ 'dark': dark, 'small': small }"
+			>
+
+			<div class="section-copy">
+				<p class="subtitle">{{ subtitle }}</p>
+				<h1 class="title">{{ title }}</h1>
+				<button v-if="buttonText" class="btn btn-outline-dark rounded-pill py-1" @click.prevent="handleLink()">{{ buttonText }}</button>
+			</div>
+
+			<div class="section-icon">
+				<i :class="iconClass"></i>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			small: {
+				type: Boolean,
+				default: false
+			},
+
+			dark: {
+				type: Boolean,
+				default: false
+			},
+
+			subtitle: {
+				type: String
+			},
+
+			title: {
+				type: String
+			},
+
+			buttonText: {
+				type: String
+			},
+
+			buttonLink: {
+				type: String
+			},
+
+			buttonEvent: {
+				type: Boolean,
+				default: false
+			},
+
+			iconClass: {
+				type: String
+			}
+		},
+
+		methods: {
+			handleLink() {
+				if(this.buttonEvent == true) {
+					this.$emit('btn-click');
+					return;
+				}
+
+				if(!this.buttonLink || this.buttonLink == undefined) {
+					swal('Oops', 'This is embarassing, we cannot redirect you to the proper page at the moment', 'warning');
+					return;
+				}
+
+				this.$router.push(this.buttonLink);
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.discover-grid-card {
+		width: 100%;
+
+		&-body {
+			width: 100%;
+			padding: 3rem 3rem 0;
+			border-radius: 8px;
+			text-align: center;
+			color: #212529;
+			background: #f8f9fa;
+			overflow: hidden;
+
+			.section-copy {
+				margin-top: 1rem;
+				margin-bottom: 1rem;
+				padding-top: 1rem;
+				padding-bottom: 1rem;
+
+				.subtitle {
+					font-size: 16px;
+					margin-bottom: 0;
+					color: #6c757d;
+					font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+					letter-spacing: -0.7px;
+				}
+
+				.title,
+				.btn {
+					font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+					letter-spacing: -0.7px;
+				}
+			}
+
+			.section-icon {
+				display: flex;
+				justify-content: center;
+				align-items: center;
+				margin-left: auto;
+				margin-right: auto;
+				width: 80%;
+				height: 300px;
+				border-radius: 21px 21px 0 0;
+				background: #232526;
+				background: linear-gradient(to right, #414345, #232526);
+				box-shadow: 0 0.125rem 0.25rem rgb(0 0 0 / 8%) !important;
+
+				i {
+					color: #fff;
+					font-size: 10rem;
+				}
+			}
+
+			&.small {
+				.section-icon {
+					height: 120px;
+
+					i {
+						font-size: 4rem;
+					}
+				}
+			}
+
+			&.dark {
+				color: #fff;
+				background: #232526;
+				background: linear-gradient(to right, #414345, #232526);
+
+				.section-icon {
+					color: #fff;
+					background: #f8f9fa;
+
+					i {
+						color: #232526;
+					}
+				}
+
+				.btn-outline-dark {
+					color: #f8f9fa;
+					border-color: #f8f9fa;
+				}
+			}
+		}
+	}
+</style>

+ 106 - 0
resources/assets/components/partials/drawer.vue

@@ -0,0 +1,106 @@
+<template>
+	<div class="app-drawer-component">
+		<div class="mobile-footer-spacer d-block d-sm-none mt-5"></div>
+
+		<div class="mobile-footer d-block d-sm-none fixed-bottom">
+			<div class="card card-body rounded-0 px-0 pt-2 pb-3 box-shadow" style="border-top:1px solid var(--border-color)">
+				<ul class="nav nav-pills nav-fill d-flex align-items-middle">
+					<li class="nav-item">
+						<router-link class="nav-link text-dark" to="/i/web">
+							<p>
+								<i class="far fa-home fa-lg"></i>
+							</p>
+							<p class="nav-link-label">
+								<span>Home</span>
+							</p>
+						</router-link>
+					</li>
+
+					<li class="nav-item">
+						<router-link class="nav-link text-dark" to="/i/web/timeline/local">
+							<p>
+								<i class="far fa-stream fa-lg"></i>
+							</p>
+							<p class="nav-link-label">
+								<span>Local</span>
+							</p>
+						</router-link>
+					</li>
+
+					<li class="nav-item">
+						<router-link class="nav-link text-dark" to="/i/web/compose">
+							<p>
+								<i class="far fa-plus-circle fa-lg"></i>
+							</p>
+							<p class="nav-link-label">
+								<span>New</span>
+							</p>
+						</router-link>
+					</li>
+
+					<li class="nav-item">
+						<router-link class="nav-link text-dark" to="/i/web/notifications">
+							<p>
+								<i class="far fa-bell fa-lg"></i>
+							</p>
+							<p class="nav-link-label">
+								<span>Alerts</span>
+							</p>
+						</router-link>
+					</li>
+
+					<li class="nav-item">
+						<router-link class="nav-link text-dark" :to="'/i/web/profile/' + user.id">
+							<p>
+								<i class="far fa-user fa-lg"></i>
+							</p>
+							<p class="nav-link-label">
+								<span>Profile</span>
+							</p>
+						</router-link>
+					</li>
+				</ul>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		data() {
+			return {
+				user: window._sharedData.user
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.app-drawer-component {
+		.nav-link {
+			padding: 0.5rem 0.1rem;
+
+			&.active {
+				background-color: transparent;
+			}
+
+			&.router-link-exact-active {
+				background-color: transparent;
+				color: var(--primary) !important;
+			}
+
+			p {
+				margin-bottom: 0;
+			}
+
+			&-label {
+				font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+				margin-top: 0;
+				font-size: 10px;
+				text-transform: uppercase;
+				font-weight: 700;
+				opacity: 0.6;
+			}
+		}
+	}
+</style>

+ 187 - 0
resources/assets/components/partials/modal/ReportPost.vue

@@ -0,0 +1,187 @@
+<template>
+	<b-modal
+		ref="modal"
+		centered
+		hide-header
+		hide-footer
+		scrollable
+		body-class="p-md-5 user-select-none"
+	>
+		<div v-if="tabIndex === 0">
+			<h2 class="text-center font-weight-bold">{{ $t('report.report') }}</h2>
+
+			<p class="text-center">{{ $t('menu.confirmReportText') }}</p>
+
+			<div v-if="status && status.hasOwnProperty('account')" class="card shadow-none rounded-lg border my-4">
+				<div class="card-body">
+					<div class="media">
+						<img
+							:src="status.account.avatar"
+							class="mr-3 rounded"
+							width="40"
+							height="40"
+							style="border-radius: 8px;"
+							onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">
+
+						<div class="media-body">
+							<p class="h5 primary font-weight-bold mb-1">
+								&commat;{{ status.account.acct }}
+							</p>
+
+							<div v-if="status.hasOwnProperty('pf_type') && status.pf_type == 'text'">
+								<p v-if="status.content_text.length <= 140" class="mb-0">
+									{{ status.content_text}}
+								</p>
+
+								<p v-else class="mb-0">
+									<span v-if="showFull">
+										{{ status.content_text}}
+										<a class="font-weight-bold primary ml-1" href="#" @click.prevent="showFull = false">Show less</a>
+									</span>
+									<span v-else>
+										{{ status.content_text.substr(0, 140) + ' ...' }}
+										<a class="font-weight-bold primary ml-1" href="#" @click.prevent="showFull = true">Show full post</a>
+									</span>
+								</p>
+							</div>
+
+							<div v-else-if="status.hasOwnProperty('pf_type') && status.pf_type == 'photo'">
+								<div class="w-100 rounded-lg d-flex justify-content-center mt-3" style="background: #000;max-height: 150px">
+									<img :src="status.media_attachments[0].url" class="rounded-lg shadow" style="width: 100%;max-height: 150px;object-fit:contain;">
+								</div>
+
+								<p v-if="status.content_text" class="mt-3 mb-0">
+									<span v-if="showFull">
+										{{ status.content_text}}
+										<a class="font-weight-bold primary ml-1" href="#" @click.prevent="showFull = false">Show less</a>
+									</span>
+									<span v-else>
+										{{ status.content_text.substr(0, 80) + ' ...' }}
+										<a class="font-weight-bold primary ml-1" href="#" @click.prevent="showFull = true">Show full post</a>
+									</span>
+								</p>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<p class="text-right mb-0 mb-md-n3">
+				<button class="btn btn-light px-3 py-2 mr-3 font-weight-bold" @click="close">{{ $t('common.cancel')}}</button>
+				<button class="btn btn-primary px-3 py-2 font-weight-bold" style="background-color: #3B82F6;" @click="tabIndex = 1">{{ $t('common.proceed') }}</button>
+			</p>
+		</div>
+
+		<div v-else-if="tabIndex === 1">
+			<h2 class="text-center font-weight-bold">{{ $t('report.report') }}</h2>
+
+			<p class="text-center">
+				{{ $t('report.selectReason') }}
+			</p>
+
+			<div class="mt-4">
+				<!-- <button class="btn btn-light btn-block rounded-pill font-weight-bold" @click="handleReason('notinterested')">I'm not interested in it</button> -->
+				<button class="btn btn-light btn-block rounded-pill font-weight-bold text-danger" @click="handleReason('spam')">{{ $t('menu.spam')}}</button>
+				<button v-if="status.sensitive == false" class="btn btn-light btn-block rounded-pill font-weight-bold text-danger" @click="handleReason('sensitive')">Adult or {{ $t('menu.sensitive')}}</button>
+				<button class="btn btn-light btn-block rounded-pill font-weight-bold text-danger" @click="handleReason('abusive')">{{ $t('menu.abusive')}}</button>
+				<button class="btn btn-light btn-block rounded-pill font-weight-bold" @click="handleReason('underage')">{{ $t('menu.underageAccount')}}</button>
+				<button class="btn btn-light btn-block rounded-pill font-weight-bold" @click="handleReason('copyright')">{{ $t('menu.copyrightInfringement')}}</button>
+				<button class="btn btn-light btn-block rounded-pill font-weight-bold" @click="handleReason('impersonation')">{{ $t('menu.impersonation')}}</button>
+				<!-- <button class="btn btn-light btn-block rounded-pill font-weight-bold" @click="handleReason('scam')">{{ $t('menu.scamOrFraud')}}</button> -->
+				<button class="btn btn-light btn-block rounded-pill mt-md-5" @click="tabIndex = 0">Go back</button>
+			</div>
+		</div>
+
+		<div v-else-if="tabIndex === 2">
+			<div class="my-4 text-center">
+				<b-spinner />
+
+				<p class="small mb-0">{{ $t('report.sendingReport') }} ...</p>
+			</div>
+		</div>
+
+		<div v-else-if="tabIndex === 3">
+			<div class="my-4">
+				<h2 class="text-center font-weight-bold mb-3">{{ $t('report.reported') }}</h2>
+				<p class="text-center py-2">
+					<span class="fa-stack fa-4x text-success">
+						<i class="far fa-check fa-stack-1x"></i>
+						<i class="fal fa-circle fa-stack-2x"></i>
+					</span>
+				</p>
+				<p class="lead text-center">{{ $t('report.thanksMsg') }}</p>
+				<hr>
+				<p class="text-center">{{ $t('report.contactAdminMsg') }}, <a href="/site/contact" class="font-weight-bold primary">{{ $t('common.clickHere') }}</a>.</p>
+			</div>
+
+			<p class="text-center mb-0 mb-md-n3">
+				<button class="btn btn-light btn-block rounded-pill px-3 py-2 mr-3 font-weight-bold" @click="close">{{ $t('common.close') }}</button>
+			</p>
+		</div>
+
+		<div v-else-if="tabIndex === 5">
+			<div class="my-4">
+				<h2 class="text-center font-weight-bold mb-3">{{ $t('common.oops') }}</h2>
+				<p class="text-center py-2">
+					<span class="fa-stack fa-3x text-danger">
+						<i class="far fa-times fa-stack-1x"></i>
+						<i class="fal fa-circle fa-stack-2x"></i>
+					</span>
+				</p>
+				<p class="lead text-center">{{ $t('common.errorMsg') }}</p>
+				<hr>
+				<p class="text-center">{{ $t('report.contactAdminMsg') }}, <a href="/site/contact" class="font-weight-bold primary">{{ $t('common.clickHere') }}</a>.</p>
+			</div>
+
+			<p class="text-center mb-0 mb-md-n3">
+				<button class="btn btn-light btn-block rounded-pill px-3 py-2 mr-3 font-weight-bold" @click="close">{{ $t('common.close') }}</button>
+			</p>
+		</div>
+	</b-modal>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			status: {
+				type: Object,
+				default: {}
+			}
+		},
+
+		data() {
+			return {
+				statusId: undefined,
+				tabIndex: 0,
+				showFull: false
+			}
+		},
+
+		methods: {
+			open() {
+				this.$refs.modal.show();
+			},
+
+			close() {
+				this.$refs.modal.hide();
+				setTimeout(() => {
+					this.tabIndex = 0;
+				}, 1000);
+			},
+
+			handleReason(reason) {
+				this.tabIndex = 2;
+
+				axios.post('/i/report', {
+					id: this.status.id,
+					report: reason,
+					type: 'post'
+				}).then(res => {
+					this.tabIndex = 3;
+				}).catch(err => {
+					this.tabIndex = 5;
+				});
+			}
+		}
+	}
+</script>

+ 148 - 0
resources/assets/components/partials/modal/UpdateAvatar.vue

@@ -0,0 +1,148 @@
+<template>
+	<b-modal
+			ref="avatarUpdateModal"
+			centered
+			hide-footer
+			header-class="py-2"
+			body-class="p-0"
+			title-class="w-100 text-center pl-4 font-weight-bold"
+			title-tag="p"
+			title="Upload Avatar"
+		>
+		<input type="file" class="d-none" ref="avatarUpdateRef" @change="handleAvatarUpdate()" accept="image/jpg,image/png">
+		<div class="d-flex align-items-center justify-content-center">
+			<div
+				v-if="avatarUpdateIndex === 0"
+				class="py-5 user-select-none cursor-pointer"
+				v-on:drop="handleDrop"
+				v-on:dragover="handleDrop"
+				@click="avatarUpdateStep(0)">
+				<p class="text-center primary">
+					<i class="fal fa-cloud-upload fa-3x"></i>
+				</p>
+				<p class="text-center lead">Drag photo here or click here</p>
+				<p class="text-center small text-muted mb-0">Must be a <strong>png</strong> or <strong>jpg</strong> image up to 2MB</p>
+			</div>
+
+			<div v-else-if="avatarUpdateIndex === 1" class="w-100 p-5">
+
+				<div class="d-md-flex justify-content-between align-items-center">
+					<div class="text-center mb-4">
+						<p class="small font-weight-bold" style="opacity:0.7;">Current</p>
+						<img :src="user.avatar" class="shadow" style="width: 150px;height: 150px;object-fit: cover;border-radius: 18px;opacity: 0.7;">
+					</div>
+
+					<div class="text-center mb-4">
+						<p class="font-weight-bold">New</p>
+						<img :src="avatarUpdatePreview" class="shadow" style="width: 220px;height: 220px;object-fit: cover;border-radius: 18px;">
+					</div>
+				</div>
+
+				<hr>
+
+				<div class="d-flex justify-content-between">
+					<button class="btn btn-light font-weight-bold btn-block mr-3" @click="avatarUpdateClear()">Clear</button>
+					<button class="btn btn-primary primary font-weight-bold btn-block mt-0" @click="confirmUpload()">Upload</button>
+				</div>
+			</div>
+		</div>
+		</b-modal>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: ['user'],
+
+		data() {
+			return {
+				loaded: false,
+				avatarUpdateIndex: 0,
+				avatarUpdateFile: undefined,
+				avatarUpdatePreview: undefined
+			}
+		},
+
+		methods: {
+			open() {
+				this.$refs.avatarUpdateModal.show();
+			},
+
+			avatarUpdateClose() {
+				this.$refs.avatarUpdateModal.hide();
+				this.avatarUpdateIndex = 0;
+				this.avatarUpdateFile = undefined;
+			},
+
+			avatarUpdateClear() {
+				this.avatarUpdateIndex = 0;
+				this.avatarUpdateFile = undefined;
+			},
+
+			avatarUpdateStep(index) {
+				this.$refs.avatarUpdateRef.click();
+				this.avatarUpdateIndex = index;
+			},
+
+			handleAvatarUpdate() {
+				let self = this;
+				let files = event.target.files;
+				Array.prototype.forEach.call(files, function(io, i) {
+					self.avatarUpdateFile = io;
+					self.avatarUpdatePreview = URL.createObjectURL(io);
+					self.avatarUpdateIndex = 1;
+				});
+			},
+
+			handleDrop(ev) {
+				ev.preventDefault();
+				let self = this;
+
+				if (ev.dataTransfer.items) {
+					for (var i = 0; i < ev.dataTransfer.items.length; i++) {
+						if (ev.dataTransfer.items[i].kind === 'file') {
+							var file = ev.dataTransfer.items[i].getAsFile();
+							if(!file) {
+								return;
+							}
+							self.avatarUpdateFile = file;
+							self.avatarUpdatePreview = URL.createObjectURL(file);
+							self.avatarUpdateIndex = 1;
+						}
+					}
+				} else {
+					for (var i = 0; i < ev.dataTransfer.files.length; i++) {
+						if(!ev.dataTransfer.files[i].hasOwnProperty('name')) {
+							return;
+						}
+						self.avatarUpdateFile = ev.dataTransfer.files[i];
+						self.avatarUpdatePreview = URL.createObjectURL(ev.dataTransfer.files[i]);
+						self.avatarUpdateIndex = 1;
+					}
+				}
+			},
+
+			confirmUpload() {
+				if(!window.confirm('Are you sure you want to change your avatar photo?')) {
+					return;
+				}
+
+				let formData = new FormData();
+				formData.append('_method', 'PATCH');
+				formData.append('avatar', this.avatarUpdateFile);
+
+				axios.post('/api/v1/accounts/update_credentials', formData)
+				.then(res => {
+					window._sharedData.user.avatar = res.data.avatar;
+					this.avatarUpdateClose();
+				})
+				.catch(err => {
+					if(err.response.data && err.response.data.errors) {
+						if(err.response.data.errors.avatar && err.response.data.errors.avatar.length) {
+							swal('Oops!', err.response.data.errors.avatar[0], 'error');
+						}
+					}
+				})
+			}
+		}
+	}
+</script>

+ 978 - 0
resources/assets/components/partials/navbar.vue

@@ -0,0 +1,978 @@
+<template>
+	<nav class="metro-nav navbar navbar-expand navbar-light navbar-laravel sticky-top shadow-none py-1">
+		<div class="container-fluid">
+				<a class="navbar-brand d-flex align-items-center" href="/i/web" title="Logo" style="width:50px">
+					<img src="/img/pixelfed-icon-color.svg" height="30px" class="px-2" loading="eager" alt="Pixelfed logo">
+					<span class="font-weight-bold mb-0 d-none d-sm-block" style="font-size:20px;">
+						{{ brandName }}
+					</span>
+				</a>
+
+				<div class="collapse navbar-collapse">
+					<div class="navbar-nav ml-auto">
+					  <!-- <form class="form-inline search-bar" method="get" action="/i/results">
+						<input class="form-control" name="q" placeholder="Search ..." aria-label="search" autocomplete="off" required style="position: relative;line-height: 0.6;width:100%;min-width: 300px;max-width: 500px;border-radius: 8px;" role="search">
+					  </form> -->
+
+						<autocomplete
+							class="searchbox"
+							:search="autocompleteSearch"
+							:placeholder="$t('navmenu.search')"
+							aria-label="Search"
+							:get-result-value="getSearchResultValue"
+							:debounceTime="700"
+							@submit="onSearchSubmit"
+							ref="autocomplete">
+
+							<template #result="{ result, props }">
+								<li
+								v-bind="props"
+								class="autocomplete-result sr"
+								>
+									<div v-if="result.s_type === 'account'" class="media align-items-center my-0">
+										<img :src="result.avatar" width="40" height="40" class="sr-avatar" style="border-radius: 40px" onerror="this.src='/storage/avatars/default.png?v=0';this.onerror=null;">
+										<div class="media-body sr-account">
+											<div class="sr-account-acct" :class="{ compact: result.acct && result.acct.length > 24 }">
+												&commat;{{ result.acct }}
+												<b-button
+													v-if="result.locked"
+													v-b-tooltip.html
+													title="Private Account"
+													variant="link"
+													size="sm"
+													class="p-0"
+													>
+													<i class="far fa-lock fa-sm text-lighter ml-1"></i>
+												</b-button>
+											</div>
+											<template v-if="result.is_admin">
+												<div class="sr-account-stats">
+													<div class="sr-account-stats-followers text-danger font-weight-bold">
+														Admin
+													</div>
+													<div>·</div>
+													<div class="sr-account-stats-followers font-weight-bold">
+														<span>{{ formatCount(result.followers_count) }}</span>
+														<span>Followers</span>
+													</div>
+												</div>
+											</template>
+											<template v-else>
+												<template v-if="result.local">
+													<div class="sr-account-stats">
+														<div v-if="result.followers_count" class="sr-account-stats-followers font-weight-bold">
+															<span>{{ formatCount(result.followers_count) }}</span>
+															<span>Followers</span>
+														</div>
+														<div v-if="result.followers_count && result.statuses_count">·</div>
+														<div v-if="result.statuses_count" class="sr-account-stats-statuses font-weight-bold">
+															<span>{{ formatCount(result.statuses_count) }}</span>
+															<span>Posts</span>
+														</div>
+														<div v-if="!result.followers_count && result.statuses_count">·</div>
+														<div class="sr-account-stats-statuses font-weight-bold">
+															<i class="far fa-clock fa-sm"></i>
+															<span>{{ timeAgo(result.created_at) }}</span>
+														</div>
+													</div>
+												</template>
+												<template v-else>
+													<div class="sr-account-stats">
+														<div v-if="result.followers_count" class="sr-account-stats-followers font-weight-bold">
+															<span>{{ formatCount(result.followers_count) }}</span>
+															<span>Followers</span>
+														</div>
+														<div v-if="result.followers_count && result.statuses_count">·</div>
+														<div v-if="result.statuses_count" class="sr-account-stats-statuses font-weight-bold">
+															<span>{{ formatCount(result.statuses_count) }}</span>
+															<span>Posts</span>
+														</div>
+														<div v-if="!result.followers_count && result.statuses_count">·</div>
+
+														<div v-if="!result.followers_count && !result.statuses_count" class="sr-account-stats-statuses font-weight-bold">
+															Remote Account
+														</div>
+														<div v-if="!result.followers_count && !result.statuses_count">
+															·
+														</div>
+														<b-button
+															v-b-tooltip.html
+															:title="'Joined ' + timeAgo(result.created_at) + ' ago'"
+															variant="link"
+															size="sm"
+															class="sr-account-stats-statuses p-0"
+															>
+															<i class="far fa-clock fa-sm"></i>
+															<span class="font-weight-bold">{{ timeAgo(result.created_at) }}</span>
+														</b-button>
+													</div>
+												</template>
+											</template>
+										</div>
+									</div>
+
+									<div v-else-if="result.s_type === 'hashtag'" class="media align-items-center my-0">
+										<div class="media-icon">
+											<i class="far fa-hashtag fa-large"></i>
+										</div>
+										<div class="media-body sr-tag">
+											<div class="sr-tag-name" :class="{ compact: result.name && result.name.length > 26 }">
+												#{{ result.name }}
+											</div>
+											<div v-if="result.count && result.count > 100" class="sr-tag-count">
+												{{ formatCount(result.count) }} {{ result.count == 1 ? 'Post' : 'Posts' }}
+											</div>
+										</div>
+									</div>
+
+									<div v-else-if="result.s_type === 'status'" class="media align-items-center my-0">
+										<img :src="result.account.avatar"  width="40" height="40" class="sr-avatar" style="border-radius: 40px" onerror="this.src='/storage/avatars/default.png?v=0';this.onerror=null;">
+
+										<div class="media-body sr-post">
+											<div class="sr-post-acct" :class="{ compact: result.acct && result.acct.length > 26 }">
+												&commat;{{ truncate(result.account.acct, 20) }}
+												<b-button
+													v-if="result.locked"
+													v-b-tooltip.html
+													title="Private Account"
+													variant="link"
+													size="sm"
+													class="p-0"
+													>
+													<i class="far fa-lock fa-sm text-lighter ml-1"></i>
+												</b-button>
+											</div>
+											<div class="sr-post-action">
+												<div class="sr-post-action-timestamp">
+													<i class="far fa-clock fa-sm"></i>
+													{{ timeAgo(result.created_at)}}
+												</div>
+												<div>·</div>
+												<div class="sr-post-action-label">
+													Tap to view post
+												</div>
+											</div>
+										</div>
+									</div>
+								</li>
+							</template>
+						</autocomplete>
+
+					</div>
+					<div class="ml-auto">
+						<ul class="navbar-nav align-items-center">
+							<!-- <li class="nav-item px-md-2 d-none d-md-block">
+								<router-link class="nav-link font-weight-bold text-dark" to="/i/web" title="Home" data-toggle="tooltip" data-placement="bottom">
+									<i class="far fa-home fa-lg"></i>
+									<span class="sr-only">Home</span>
+								</router-link>
+							</li>
+							<li class="nav-item px-md-2 d-none d-md-block">
+								<router-link class="nav-link font-weight-bold text-dark" title="Compose" data-toggle="tooltip" data-placement="bottom" to="/i/web/compose">
+									<i class="far fa-plus-square fa-lg"></i>
+									<span class="sr-only">Compose</span>
+								</router-link>
+							</li> -->
+							<!-- <li class="nav-item px-md-2">
+								<router-link class="nav-link font-weight-bold text-dark" to="/i/web/direct" title="Direct" data-toggle="tooltip" data-placement="bottom">
+									<i class="far fa-comment-dots fa-lg"></i>
+									<span class="sr-only">Direct</span>
+								</router-link>
+							</li>
+							<li class="nav-item px-md-2 d-none d-md-block">
+								<router-link class="nav-link font-weight-bold text-dark fa-layers fa-fw" to="/i/web/notifications" title="Notifications" data-toggle="tooltip" data-placement="bottom">
+									<i class="far fa-bell fa-lg"></i>
+									<span class="fa-layers-counter" style="background:Tomato"></span>
+									<span class="sr-only">Notifications</span>
+								</router-link>
+							</li> -->
+							<li class="nav-item dropdown ml-2">
+								<a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="User Menu">
+									<i class="d-none far fa-user fa-lg text-dark"></i>
+									<span class="sr-only">User Menu</span>
+									<img :src="user.avatar" class="nav-avatar rounded-circle border shadow" width="30" height="30" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+								</a>
+
+								<div class="dropdown-menu dropdown-menu-right shadow" aria-labelledby="navbarDropdown">
+									<ul class="nav flex-column">
+										<li class="nav-item nav-icons">
+											<div class="d-flex justify-content-between align-items-center">
+												<router-link class="nav-link text-center" to="/i/web">
+													<div class="icon text-lighter"><i class="far fa-home fa-lg"></i></div>
+													<div class="small">{{ $t('navmenu.homeFeed') }}</div>
+												</router-link>
+
+												<router-link v-if="hasLocalTimeline" class="nav-link text-center" :to="{ name: 'timeline', params: { scope: 'local' } }">
+													<div class="icon text-lighter"><i class="fas fa-stream fa-lg"></i></div>
+													<div class="small">{{ $t('navmenu.localFeed') }}</div>
+												</router-link>
+
+												<router-link v-if="hasNetworkTimeline" class="nav-link text-center" :to="{ name: 'timeline', params: { scope: 'global' } }">
+													<div class="icon text-lighter"><i class="far fa-globe fa-lg"></i></div>
+													<div class="small">{{ $t('navmenu.globalFeed') }}</div>
+												</router-link>
+											</div>
+										</li>
+
+										<li class="nav-item nav-icons">
+											<div class="d-flex justify-content-between align-items-center">
+												<router-link class="nav-link text-center" to="/i/web/discover">
+													<div class="icon text-lighter"><i class="far fa-compass"></i></div>
+													<div class="small">{{ $t('navmenu.discover') }}</div>
+												</router-link>
+
+												<router-link class="nav-link text-center" to="/i/web/notifications">
+													<div class="icon text-lighter">
+														<i class="far fa-bell"></i>
+													</div>
+													<div class="small">
+														{{ $t('navmenu.notifications') }}
+													</div>
+												</router-link>
+
+												<router-link class="nav-link text-center px-3" :to="'/i/web/profile/' + user.id">
+													<div class="icon text-lighter">
+														<i class="far fa-user"></i>
+													</div>
+													<div class="small">{{ $t('navmenu.profile') }}</div>
+												</router-link>
+											</div>
+											<hr class="mb-0" style="margin-top: -5px;opacity: 0.4;" />
+										</li>
+
+										<li class="nav-item">
+											<router-link class="nav-link" to="/i/web/compose">
+												<span class="icon text-lighter"><i class="far fa-plus-square"></i></span>
+												{{ $t('navmenu.compose') }}
+											</router-link>
+										</li>
+
+										<!-- <li class="nav-item">
+											<router-link class="nav-link" to="/i/web/discover">
+												<span class="icon text-lighter"><i class="far fa-compass"></i></span>
+												{{ $t('navmenu.discover') }}
+											</router-link>
+										</li> -->
+
+										<li class="nav-item">
+											<router-link class="nav-link d-flex justify-content-between align-items-center" to="/i/web/direct">
+												<span>
+													<span class="icon text-lighter">
+														<i class="far fa-envelope"></i>
+													</span>
+													{{ $t('navmenu.directMessages') }}
+												</span>
+
+												<!-- <span class="badge badge-danger font-weight-light rounded-pill px-2" style="transform:scale(0.86)">99+</span> -->
+											</router-link>
+										</li>
+
+										<li class="nav-item">
+											<a class="nav-link" href="/i/web" @click.prevent="openUserInterfaceSettings">
+												<span class="icon text-lighter"><i class="far fa-brush"></i></span>
+												UI Settings
+											</a>
+										</li>
+
+										<!-- <li class="nav-item">
+											<router-link class="nav-link d-flex justify-content-between align-items-center" to="/i/web/notifications">
+												<span>
+													<span class="icon text-lighter">
+														<i class="far fa-bell"></i>
+													</span>
+													{{ $t('navmenu.notifications') }}
+												</span>
+											</router-link>
+										</li> -->
+
+										<!-- <li class="nav-item">
+											<hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;" />
+
+											<router-link class="nav-link" :to="'/i/web/profile/' + user.id">
+												<span class="icon text-lighter">
+													<i class="far fa-user"></i>
+												</span>
+												{{ $t('navmenu.profile') }}
+											</router-link>
+										</li> -->
+										<li v-if="user.is_admin" class="nav-item">
+											<hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;" />
+											<a class="nav-link" href="/i/admin/dashboard">
+												<span class="icon text-lighter">
+													<i class="far fa-tools"></i>
+												</span>
+												{{ $t('navmenu.admin') }}
+											</a>
+										</li>
+
+										<li class="nav-item">
+											<hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;" />
+											<a class="nav-link" href="/">
+												<span class="icon text-lighter">
+													<i class="fas fa-chevron-left"></i>
+												</span>
+												{{ $t('navmenu.backToPreviousDesign') }}
+											</a>
+										</li>
+
+										<li class="nav-item">
+											<hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;" />
+											<a class="nav-link" href="/" @click.prevent="logout()">
+												<span class="icon text-lighter">
+													<i class="far fa-sign-out"></i>
+												</span>
+												{{ $t('navmenu.logout') }}
+											</a>
+										</li>
+									</ul>
+								</div>
+							</li>
+						</ul>
+					</div>
+				</div>
+		</div>
+
+		<b-modal
+			ref="uis"
+			hide-footer
+			centered
+			body-class="p-0 ui-menu"
+			title="UI Settings">
+			<div class="list-group list-group-flush">
+				<div class="list-group-item px-3">
+					<div class="d-flex justify-content-between align-items-center">
+						<div>
+							<p class="font-weight-bold mb-1">Theme</p>
+							<p class="small text-muted mb-0"></p>
+						</div>
+
+						<div class="btn-group btn-group-sm">
+							<button
+								class="btn"
+								:class="[ uiColorScheme == 'system' ? 'btn-primary' : 'btn-outline-primary']"
+								@click="toggleUi('system')">
+								Auto
+							</button>
+							<button
+								class="btn"
+								:class="[ uiColorScheme == 'light' ? 'btn-primary' : 'btn-outline-primary']"
+								@click="toggleUi('light')">
+								Light mode
+							</button>
+							<button
+								class="btn"
+								:class="[ uiColorScheme == 'dark' ? 'btn-primary' : 'btn-outline-primary']"
+								@click="toggleUi('dark')">
+								Dark mode
+							</button>
+						</div>
+					</div>
+				</div>
+
+				<div class="list-group-item px-3">
+					<div class="d-flex justify-content-between align-items-center">
+						<div>
+							<p class="font-weight-bold mb-1">Profile Layout</p>
+							<p class="small text-muted mb-0"></p>
+						</div>
+
+						<div class="btn-group btn-group-sm">
+							<button
+								class="btn"
+								:class="[ profileLayout == 'grid' ? 'btn-primary' : 'btn-outline-primary']"
+								@click="toggleProfileLayout('grid')">
+								Grid
+							</button>
+							<button
+								class="btn"
+								:class="[ profileLayout == 'masonry' ? 'btn-primary' : 'btn-outline-primary']"
+								@click="toggleProfileLayout('masonry')">
+								Masonry
+							</button>
+							<button
+								class="btn"
+								:class="[ profileLayout == 'feed' ? 'btn-primary' : 'btn-outline-primary']"
+								@click="toggleProfileLayout('feed')">
+								Feed
+							</button>
+						</div>
+					</div>
+				</div>
+
+				<div class="list-group-item px-3">
+					<div class="d-flex justify-content-between align-items-center">
+						<div>
+							<p class="font-weight-bold mb-0">Compact Media Previews</p>
+						</div>
+						<b-form-checkbox v-model="fixedHeight" switch size="lg" />
+					</div>
+				</div>
+
+				<div class="list-group-item px-3">
+					<div class="d-flex justify-content-between align-items-center">
+						<div>
+							<p class="font-weight-bold mb-0">Load Comments</p>
+						</div>
+						<b-form-checkbox v-model="autoloadComments" switch size="lg" />
+					</div>
+				</div>
+
+				<div class="list-group-item px-3">
+					<div class="d-flex justify-content-between align-items-center">
+						<div>
+							<p class="font-weight-bold mb-0">Hide Counts & Stats</p>
+						</div>
+						<b-form-checkbox v-model="hideCounts" switch size="lg" />
+					</div>
+				</div>
+
+			</div>
+		</b-modal>
+	</nav>
+</template>
+
+<script type="text/javascript">
+	import Autocomplete from '@trevoreyre/autocomplete-vue'
+	import '@trevoreyre/autocomplete-vue/dist/style.css'
+
+	export default {
+		components: {
+			Autocomplete
+		},
+
+		data() {
+			return {
+				brandName: 'pixelfed',
+				user: window._sharedData.user,
+				profileLayoutModel: 'grid',
+				hasLocalTimeline: true,
+				hasNetworkTimeline: false
+			}
+		},
+
+		computed: {
+			profileLayout: {
+				get() {
+					return this.$store.state.profileLayout;
+				},
+
+				set(val) {
+					this.$store.commit('setProfileLayout', val);
+				}
+			},
+
+			hideCounts: {
+				get() {
+					return this.$store.state.hideCounts;
+				},
+
+				set(val) {
+					this.$store.commit('setHideCounts', val);
+				}
+			},
+			autoloadComments: {
+				get() {
+					return this.$store.state.autoloadComments;
+				},
+
+				set(val) {
+					this.$store.commit('setAutoloadComments', val);
+				}
+			},
+			newReactions: {
+				get() {
+					return this.$store.state.newReactions;
+				},
+
+				set(val) {
+					this.$store.commit('setNewReactions', val);
+				}
+			},
+
+			fixedHeight: {
+				get() {
+					return this.$store.state.fixedHeight;
+				},
+
+				set(val) {
+					this.$store.commit('setFixedHeight', val);
+				}
+			},
+
+			uiColorScheme: {
+				get() {
+					return this.$store.state.colorScheme;
+				},
+
+				set(val) {
+					this.$store.commit('setColorScheme', val);
+				}
+			}
+		},
+
+		mounted() {
+			if(window.App.config.features.hasOwnProperty('timelines')) {
+				this.hasLocalTimeline = App.config.features.timelines.local;
+				this.hasNetworkTimeline = App.config.features.timelines.network;
+			}
+
+			let u = new URLSearchParams(window.location.search);
+			if(u.has('q') && u.get('q') && u.has('src') && u.get('src') === 'ac') {
+				this.$refs.autocomplete.setValue(u.get('q'));
+				setTimeout(() => {
+					let ai = document.querySelector('.autocomplete-input')
+					ai.focus();
+				}, 1000)
+			}
+
+			this.brandName = window.App.config.site.name;
+		},
+
+		methods: {
+			autocompleteSearch(q) {
+				if (!q || q.length < 2) {
+					return [];
+				}
+
+				let resolve = q.startsWith('https://') || q.startsWith('@');
+
+				return axios.get('/api/v2/search', {
+					params: {
+						q: q,
+						resolve: resolve,
+						'_pe': 1
+					}
+				}).then(res => {
+					let results = [];
+					let accounts = res.data.accounts.map(res => {
+						let account = res;
+						account.s_type = 'account';
+						return account;
+					});
+					let hashtags = res.data.hashtags.map(res => {
+						let tag = res;
+						tag.s_type = 'hashtag';
+						return tag;
+					})
+					// let statuses = res.data.statuses.map(res => {
+					// 	let status = res;
+					// 	status.s_type = 'status';
+					// 	return status;
+					// });
+
+					// results.push(...statuses.slice(0,5));
+					results.push(...accounts.slice(0,5));
+					results.push(...hashtags.slice(0,5));
+
+					if(res.data.statuses) {
+						if(Array.isArray(res.data.statuses)) {
+							let statuses = res.data.statuses.map(res => {
+								let status = res;
+								status.s_type = 'status';
+								return status;
+							});
+							results.push(...statuses);
+						} else {
+							if(q === res.data.statuses.url) {
+								this.$refs.autocomplete.value = '';
+
+								this.$router.push({
+									name: 'post',
+									path: `/i/web/post/${res.data.statuses.id}`,
+									params: {
+										id: res.data.statuses.id,
+										cachedStatus: res.data.statuses,
+										cachedProfile: this.user
+									}
+								});
+							}
+						}
+					}
+					return results;
+				});
+			},
+
+			getSearchResultValue(result) {
+				return result;
+			},
+
+			onSearchSubmit(result) {
+				if (result.length < 1) {
+					return;
+				}
+				this.$refs.autocomplete.value = '';
+				switch(result.s_type) {
+					case 'account':
+						// this.$router.push({
+						// 	name: 'profile',
+						// 	path: `/i/web/profile/${result.id}`,
+						// 	params: {
+						// 		id: result.id,
+						// 		cachedProfile: result,
+						// 		cachedUser: this.user
+						// 	}
+						// });
+						location.href = `/i/web/profile/${result.id}`;
+					break;
+
+					case 'hashtag':
+						// this.$router.push({
+						// 	name: 'hashtag',
+						// 	path: `/i/web/hashtag/${result.name}`,
+						// 	params: {
+						// 		id: result.name,
+						// 	}
+						// });
+						location.href = `/i/web/hashtag/${result.name}`;
+					break;
+
+					case 'status':
+						// this.$router.push({
+						// 	name: 'post',
+						// 	path: `/i/web/post/${result.id}`,
+						// 	params: {
+						// 		id: result.id,
+						// 	}
+						// });
+						location.href = `/i/web/post/${result.id}`;
+					break;
+				}
+			},
+
+			truncate(text, limit = 30) {
+				if(text.length <= limit) {
+					return text;
+				}
+
+				return text.slice(0, limit) + '...'
+			},
+
+			timeAgo(ts) {
+				return window.App.util.format.timeAgo(ts);
+			},
+
+			formatCount(val) {
+				if(!val) {
+					return 0;
+				}
+
+				return new Intl.NumberFormat('en-CA', { notation: 'compact' , compactDisplay: "short" }).format(val);
+			},
+
+			logout() {
+				axios.post('/logout')
+				.then(res => {
+					location.href = '/';
+				}).catch(err => {
+					location.href = '/';
+				})
+			},
+
+			openUserInterfaceSettings() {
+				event.currentTarget.blur();
+				this.$refs.uis.show();
+			},
+
+			toggleUi(ui) {
+				event.currentTarget.blur();
+				this.uiColorScheme = ui;
+			},
+
+			toggleProfileLayout(layout) {
+				event.currentTarget.blur();
+				this.profileLayout = layout;
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.metro-nav {
+		z-index: 4;
+
+		.dropdown-menu {
+			min-width: 18rem;
+			padding: 0;
+			border: none;
+
+			.nav {
+				overflow: auto;
+			}
+
+			.nav-item {
+				.nav-link {
+					font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+					font-weight: 500;
+					color: rgba(156,163,175, 1);
+					padding-left: 14px;
+					margin-bottom: 5px;
+
+					.icon {
+						display: inline-block;
+						width: 40px;
+						text-align: center;
+					}
+
+				}
+
+				.router-link-exact-active {
+					color: var(--primary);
+					font-weight: 700;
+					padding-left: 14px;
+
+					&:not(.text-center) {
+						padding-left: 10px;
+						border-left: 4px solid var(--primary);
+					}
+
+					.icon {
+						color: var(--primary) !important;
+					}
+				}
+
+				&.nav-icons {
+					.small {
+						font-weight: 700 !important;
+					}
+				}
+
+				&:is(:last-child) {
+					.nav-link {
+						margin-bottom: 0;
+						border-bottom-left-radius: 15px;
+						border-bottom-right-radius: 15px;
+					}
+				}
+			}
+		}
+
+		.fa-layers {
+			display: inline-block;
+			height: 1em;
+			position: relative;
+			text-align: center;
+			vertical-align: -0.125em;
+			width: 1em;
+
+			.fa-layers-counter {
+				background-color: #ff253a;
+				border-radius: 1em;
+				-webkit-box-sizing: border-box;
+				box-sizing: border-box;
+				color: #fff;
+				height: 1.5em;
+				line-height: 1;
+				max-width: 5em;
+				min-width: 1.5em;
+				overflow: hidden;
+				padding: 0.25em;
+				right: 0;
+				text-overflow: ellipsis;
+				top: 0;
+				transform: scale(.5);
+				-webkit-transform-origin: top right;
+				transform-origin: top right;
+
+				display: inline-block;
+			    position: absolute;
+
+			    margin-right: -5px;
+			    margin-top: -10px;
+			}
+
+			.far {
+				bottom: 0;
+				left: 0;
+				margin: auto;
+				position: absolute;
+				right: 0;
+				top: 0;
+			}
+		}
+
+		.searchbox {
+			@media (min-width: 768px) {
+				width: 300px;
+			}
+		}
+
+		.nav-avatar {
+			@media (min-width: 768px) {
+				width: 50px;
+				height: 50px;
+			}
+		}
+
+		.autocomplete[data-loading="true"]::after {
+			content: "";
+			border-right: 3px solid var(--primary);
+		}
+
+		.autocomplete {
+			&-input {
+				padding: 0.375rem 0.75rem 0.375rem 2.6rem;
+				background-color: var(--light-gray);
+				font-size: 0.9rem;
+				border-radius: 50rem;
+				background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQjhDMkNDIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGNpcmNsZSBjeD0iMTEiIGN5PSIxMSIgcj0iNSIvPjxwYXRoIGQ9Ik0xOSAxOWwtNC00Ii8+PC9zdmc+");
+				box-shadow: 0 0.125rem 0.25rem rgb(0 0 0 / 8%) !important;
+			}
+
+			&-result {
+				background-image: none;
+				padding: 10px 12px;
+				cursor: pointer;
+
+				&-list {
+					box-shadow: 0 0.125rem 0.45rem var(--border-color);
+					-ms-overflow-style: none;
+					scrollbar-width: none;
+
+					&::-webkit-scrollbar {
+						width: 0 !important
+					}
+				}
+
+				.media-icon {
+					display: flex;
+					justify-content: center;
+					align-items: center;
+					width: 40px;
+					height: 40px;
+					margin-right: 12px;
+					background: var(--light-gray);
+					border: 1px solid var(--input-border);
+					border-radius: 40px;
+					box-shadow: 0 0.125rem 0.25rem rgb(0 0 0 / 8%);
+				}
+
+			}
+		}
+
+		.sr {
+			&:not(:last-child) {
+				border-bottom: 1px solid var(--input-border);
+			}
+
+			&-avatar {
+				margin-right: 12px;
+				box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)
+			}
+
+			&-account {
+				display: flex;
+				flex-direction: column;
+				align-items: flex-start;
+				justify-content: center;
+				gap: 3px;
+
+				&-acct {
+					word-wrap: break-word;
+					word-break: break-all;
+					font-size: 14px;
+					line-height: 18px;
+					font-weight: bold;
+					color: var(--dark);
+					margin-right: 1rem;
+
+					&.compact {
+						font-size: 12px;
+					}
+				}
+
+				&-stats {
+					display: flex;
+					align-items: center;
+					gap: 5px;
+					line-height: 14px;
+
+					&-followers,
+					&-statuses {
+						font-size: 11px;
+						font-weight: 500;
+						color: var(--text-lighter);
+					}
+				}
+			}
+
+			&-tag {
+				display: flex;
+				flex-direction: column;
+				align-items: flex-start;
+				justify-content: center;
+				gap: 3px;
+
+				&-name {
+					word-wrap: break-word;
+					word-break: break-all;
+					font-size: 14px;
+					line-height: 18px;
+					font-weight: bold;
+					color: var(--dark);
+					margin-right: 1rem;
+
+					&.compact {
+						font-size: 12px;
+					}
+				}
+
+				&-count {
+					font-size: 11px;
+					line-height: 13px;
+					color: var(--text-lighter);
+					font-weight: bold;
+				}
+			}
+
+			&-post {
+				display: flex;
+				flex-direction: column;
+				align-items: flex-start;
+				justify-content: center;
+				gap: 3px;
+
+				&-acct {
+					font-size: 14px;
+					line-height: 18px;
+					font-weight: bold;
+					color: var(--dark);
+				}
+
+				&-action {
+					display: flex;
+					font-size: 11px;
+					line-height: 14px;
+					color: var(--text-lighter);
+					font-weight: 500;
+					gap: 3px;
+					align-items: center;
+
+					&-timestamp {
+						font-weight: 700;
+					}
+
+					&-label {
+						font-weight: 700;
+					}
+				}
+			}
+		}
+	}
+
+	.force-dark-mode {
+		.autocomplete-result-list {
+			border-color: var(--input-border);
+		}
+
+		.autocomplete-result:hover, .autocomplete-result[aria-selected=true] {
+			box-shadow: 0;
+    		background-color: rgba(255, 255, 255, .1);
+		}
+
+		.autocomplete[data-loading="true"]::after {
+			content: "";
+			border: 3px solid rgba(255, 255, 255, 0.22);
+			border-right: 3px solid var(--primary);
+		}
+	}
+</style>

+ 10 - 0
resources/assets/components/partials/placeholders/DirectMessagePlaceholder.vue

@@ -0,0 +1,10 @@
+<template>
+	<div class="ph-item border-0 shadow-sm p-1" style="border-radius:15px;margin-bottom: 1rem;">
+		<div class="ph-col-12">
+			<div class="ph-row align-items-center mt-0">
+				<div class="ph-avatar mr-3 d-flex" style="min-width: 40px;width:40px!important;height:40px!important;border-radius: 15px;"></div>
+				<div class="ph-col-6 big"></div>
+			</div>
+		</div>
+	</div>
+</template>

+ 6 - 0
resources/assets/components/partials/placeholders/EmptyTimeline.vue

@@ -0,0 +1,6 @@
+<template>
+	<div class="card card-body shadow-sm mb-3" style="border-radius: 15px;">
+		<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="max-height: 300px;opacity: 0.6;">
+		<p class="lead mb-0 text-center">This feed is empty</p>
+	</div>
+</template>

+ 30 - 0
resources/assets/components/partials/placeholders/NotificationPlaceholder.vue

@@ -0,0 +1,30 @@
+<template>
+	<div v-if="small" class="ph-item border-0 mb-0 p-0" style="border-radius:15px;margin-left:-14px;">
+		<div class="ph-col-12 mb-0">
+			<div class="ph-row align-items-center mt-0">
+				<div class="ph-avatar mr-2 d-flex" style="min-width: 32px;width:32px!important;height:32px!important;border-radius: 40px;"></div>
+				<div class="ph-col-6"></div>
+			</div>
+		</div>
+	</div>
+
+	<div v-else class="ph-item border-0 shadow-sm p-1" style="border-radius:15px;margin-bottom: 1rem;">
+		<div class="ph-col-12">
+			<div class="ph-row align-items-center mt-0">
+				<div class="ph-avatar mr-3 d-flex" style="min-width: 40px;width:40px!important;height:40px!important;border-radius: 15px;"></div>
+				<div class="ph-col-6 big"></div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			small: {
+				type: Boolean,
+				default: false
+			}
+		}
+	}
+</script>

+ 105 - 0
resources/assets/components/partials/placeholders/TimelineOnboarding.vue

@@ -0,0 +1,105 @@
+<template>
+	<div class="timeline-onboarding">
+		<div class="card card-body shadow-sm mb-3 p-5" style="border-radius: 15px;">
+			<h1 class="text-center mb-4">✨ {{ $t('timeline.onboarding.welcome') }}</h1>
+
+			<p class="text-center mb-3" style="font-size: 22px;">
+				{{ $t('timeline.onboarding.thisIsYourHomeFeed') }}
+			</p>
+
+			<p class="text-center lead">{{ $t('timeline.onboarding.letUsHelpYouFind') }}</p>
+
+			<p v-if="newlyFollowed" class="text-center mb-0">
+				<a href="/i/web" class="btn btn-primary btn-lg primary font-weight-bold rounded-pill px-4" onclick="location.reload()">
+					{{ $t('timeline.onboarding.refreshFeed') }}
+				</a>
+			</p>
+		</div>
+
+		<div class="row">
+			<div class="col-12 col-md-6 mb-3" v-for="(profile, index) in popularAccounts">
+				<div class="card shadow-sm border-0 rounded-px">
+					<div class="card-body p-2">
+						<profile-card
+							:key="'pfc' + index"
+							:profile="profile"
+							class="w-100"
+							v-on:follow="follow(index)"
+							v-on:unfollow="unfollow(index)"
+						/>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import ProfileCard from './../profile/ProfileHoverCard.vue';
+
+	export default {
+		props: {
+			profile: {
+				type: Object
+			}
+		},
+
+		components: {
+			"profile-card": ProfileCard
+		},
+
+		data() {
+			return {
+				popularAccounts: [],
+				newlyFollowed: 0
+			};
+		},
+
+		mounted() {
+			this.fetchPopularAccounts();
+		},
+
+		methods: {
+			fetchPopularAccounts() {
+        		axios.get('/api/pixelfed/discover/accounts/popular')
+        		.then(res => {
+        			this.popularAccounts = res.data;
+        		})
+        	},
+
+        	follow(index) {
+        		axios.post('/api/v1/accounts/' + this.popularAccounts[index].id + '/follow')
+				.then(res => {
+					this.newlyFollowed++;
+					this.$store.commit('updateRelationship', [res.data]);
+					this.$emit('update-profile', {
+						'following_count': this.profile.following_count + 1
+					})
+				});
+        	},
+
+        	unfollow(index) {
+        		axios.post('/api/v1/accounts/' + this.popularAccounts[index].id + '/unfollow')
+				.then(res => {
+					this.newlyFollowed--;
+					this.$store.commit('updateRelationship', [res.data]);
+					this.$emit('update-profile', {
+						'following_count': this.profile.following_count - 1
+					})
+				});
+        	}
+		}
+	}
+</script>
+
+<style lang="scss">
+.timeline-onboarding {
+	.profile-hover-card-inner {
+		width: 100%;
+
+		.d-flex {
+			max-width: 100% !important;
+		}
+	}
+}
+</style>

+ 1066 - 0
resources/assets/components/partials/post/CommentDrawer.vue

@@ -0,0 +1,1066 @@
+<template>
+	<div class="post-comment-drawer">
+		<input type="file" ref="fileInput" class="d-none" accept="image/jpeg,image/png" @change="handleImageUpload">
+
+		<div class="post-comment-drawer-feed">
+			<div v-if="feed.length && feed.length >= 1" class="mb-2 sort-menu">
+				<b-dropdown size="sm" variant="link" ref="sortMenu" toggle-class="text-decoration-none text-dark font-weight-bold" no-caret>
+					<template #button-content>
+						Show {{ sorts[sortIndex] }} comments <i class="far fa-chevron-down ml-1"></i>
+					</template>
+					<b-dropdown-item href="#" :class="{ active: sortIndex === 0 }" @click="toggleSort(0)">
+						<p class="title mb-0">All</p>
+						<p class="description">All comments in chronological order</p>
+					</b-dropdown-item>
+					<b-dropdown-item href="#" :class="{ active: sortIndex === 1 }" @click="toggleSort(1)">
+						<p class="title mb-0">Newest</p>
+						<p class="description">Newest comments appear first</p>
+					</b-dropdown-item>
+					<b-dropdown-item href="#" :class="{ active: sortIndex === 2 }" @click="toggleSort(2)">
+						<p class="title mb-0">Popular</p>
+						<p class="description">The most relevant comments appear first</p>
+					</b-dropdown-item>
+				</b-dropdown>
+			</div>
+
+			<div v-if="feedLoading" class="post-comment-drawer-feed-loader">
+				<b-spinner />
+			</div>
+
+			<div v-else>
+				<transition-group tag="div" enter-active-class="animate__animated animate__fadeIn" leave-active-class="animate__animated animate__fadeOut" mode="out-in">
+					<div
+						v-for="(post, idx) in feed"
+						:key="'cd:' + post.id + ':' + idx"
+						class="media media-status align-items-top mb-3"
+                        :style="{ opacity: deletingIndex && deletingIndex === idx ? 0.3 : 1 }">
+
+						<a href="#l">
+							<img class="shadow-sm media-avatar border" :src="getPostAvatar(post)" width="40" height="40" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">
+						</a>
+
+						<div class="media-body">
+							<div class="media-body-wrapper">
+								<div v-if="!post.media_attachments.length" class="media-body-comment">
+									<p class="media-body-comment-username">
+										<a :href="post.account.url" :id="'acpop_'+post.id" tabindex="0" @click.prevent="goToProfile(post.account)">
+											{{ post.account.acct }}
+										</a>
+
+										<b-popover :target="'acpop_'+post.id" triggers="hover" placement="bottom" custom-class="shadow border-0 rounded-px" :delay="750">
+											<profile-hover-card
+												:profile="post.account"
+												v-on:follow="follow(idx)"
+												v-on:unfollow="unfollow(idx)" />
+										</b-popover>
+									</p>
+
+									<span v-if="post.sensitive">
+										<p class="mb-0">
+											{{ $t('common.sensitiveContentWarning') }}
+										</p>
+										<a href="#" class="small font-weight-bold primary" @click.prevent="post.sensitive = false">Show</a>
+									</span>
+
+									<!-- <span v-else v-html="post.content"></span> -->
+									<read-more v-else :status="post" />
+
+									<button
+										v-if="post.favourites_count && !hideCounts"
+										class="btn btn-link media-body-likes-count shadow-sm"
+										@click.prevent="showLikesModal(idx)">
+										<i class="far fa-thumbs-up primary"></i>
+										<span class="count">{{ prettyCount(post.favourites_count) }}</span>
+									</button>
+								</div>
+
+								<div v-else>
+                                    <div :class="[ post.content && post.content.length || post.media_attachments.length ? 'media-body-comment' : '']">
+    									<p class="media-body-comment-username">
+    										<a :href="post.account.url" @click.prevent="goToProfile(post.account)">
+    											{{ post.account.acct }}
+    										</a>
+    									</p>
+    									<div v-if="post.sensitive" class="bh-comment" @click="post.sensitive = false">
+    										<blur-hash-image
+    											:width="blurhashWidth(post)"
+    											:height="blurhashHeight(post)"
+    											:punch="1"
+    											class="img-fluid border shadow"
+    											:hash="post.media_attachments[0].blurhash"
+    											 />
+
+    										<div class="sensitive-warning">
+    											<p class="mb-0"><i class="far fa-eye-slash fa-lg"></i></p>
+    											<p class="mb-0 small">Tap to view</p>
+    										</div>
+    									</div>
+
+                                        <read-more :status="post" class="mb-1" />
+
+    									<div v-if="!post.sensitive"
+                                            class="bh-comment"
+                                            :class="[post.media_attachments.length > 1 ? 'bh-comment-borderless' : '']"
+                                            :style="{
+                                                'max-width': post.media_attachments.length > 1 ? '100% !important' : '160px',
+                                                'max-height': post.media_attachments.length > 1 ? '100% !important' : '260px',
+                                            }">
+                                            <div v-if="post.media_attachments[0].type == 'image'">
+                                                <div v-if="post.media_attachments.length == 1">
+            										<div @click="lightbox(post)">
+            											<blur-hash-image
+            												:width="blurhashWidth(post)"
+            												:height="blurhashHeight(post)"
+            												:punch="1"
+            												class="img-fluid border shadow"
+            												:hash="post.media_attachments[0].blurhash"
+            												:src="getMediaSource(post)"
+            											 />
+            										</div>
+                                                </div>
+                                                <div v-else
+                                                    style="
+                                                        display: grid;
+                                                        grid-auto-flow:column;
+                                                        gap:1px;
+                                                        grid-template-rows: [row1-start] 50% [row1-end row2-start] 50% [row2-end];
+                                                        grid-template-columns: [column1-start] 50% [column1-end column2-start] 50% [column2-end];
+                                                        border-radius: 8px;
+                                                    ">
+                                                    <div v-for="(albumMedia, idx) in post.media_attachments.slice(0, 4)" @click="lightbox(post, idx)">
+                                                        <blur-hash-image
+                                                            :width="30"
+                                                            :height="30"
+                                                            :punch="1"
+                                                            class="img-fluid shadow"
+                                                            :hash="post.media_attachments[idx].blurhash"
+                                                            :src="getMediaSource(post, idx)"
+                                                         />
+                                                    </div>
+                                                </div>
+                                            </div>
+
+                                            <div v-else="post.media_attachments[0].type == 'vaideo'">
+                                                <div @click="lightbox(post)" class="cursor-pointer">
+                                                    <div style="position: relative;" class="d-flex align-items-center justify-content-center">
+                                                        <div style="position: absolute;width: 40px; height: 40px; background-color: rgba(0, 0, 0, 0.5);border-radius: 40px;" class="d-flex justify-content-center align-items-center">
+                                                            <i class="far fa-play pl-1 text-white fa-lg"></i>
+                                                        </div>
+                                                        <video :src="post.media_attachments[0].url" class="img-fluid" style="max-height: 200px"/>
+                                                    </div>
+                                                </div>
+                                            </div>
+
+                                            <div v-else>
+                                                <p>Cannot render commment</p>
+                                            </div>
+
+    										<button
+    											v-if="post.favourites_count && !hideCounts"
+    											class="btn btn-link media-body-likes-count shadow-sm"
+    											@click.prevent="showLikesModal(idx)">
+    											<i class="far fa-thumbs-up primary"></i>
+    											<span class="count">{{ prettyCount(post.favourites_count) }}</span>
+    										</button>
+                                        </div>
+                                    </div>
+								</div>
+							</div>
+
+							<p class="media-body-reactions">
+								<button
+									class="btn btn-link font-weight-bold btn-sm p-0"
+									:class="[ post.favourited ? 'primary' : 'text-muted' ]"
+									@click="likeComment(idx)">
+									{{ post.favourited ? 'Liked' : 'Like' }}
+								</button>
+								<template v-if="post.visibility != 'public'">
+									<span class="mx-1">·</span>
+									<span
+										v-if="post.visibility === 'unlisted'"
+										class="text-lighter"
+										v-b-tooltip:hover.bottom
+										title="This post is unlisted on timelines">
+										<i class="far fa-unlock fa-sm"></i>
+									</span>
+									<span
+										v-else-if="post.visibility === 'private'"
+										class="text-muted"
+										v-b-tooltip:hover.bottom
+										title="This post is only visible to followers of this account">
+										<i class="far fa-lock fa-sm"></i>
+									</span>
+								</template>
+								<span class="mx-1">·</span>
+								<a class="font-weight-bold text-muted" :href="post.url" @click.prevent="toggleCommentReply(idx)">
+									Reply
+								</a>
+								<span class="mx-1">·</span>
+								<a class="font-weight-bold text-muted" :href="post.url" @click.prevent="goToPost(post)" v-once>
+									{{ timeago(post.created_at) }}
+								</a>
+								<span v-if="profile && post.account.id === profile.id || status.account.id === profile.id">
+									<span class="mx-1">·</span>
+									<a
+										class="font-weight-bold"
+                                        :class="[deletingIndex && deletingIndex === idx ? 'text-danger' : 'text-muted']"
+										href="#"
+										@click.prevent="deleteComment(idx)">
+                                        {{ deletingIndex && deletingIndex === idx ? 'Deleting...' : 'Delete'}}
+									</a>
+								</span>
+
+								<span v-else>
+									<span class="mx-1">·</span>
+									<a
+										class="font-weight-bold text-muted"
+										href="#"
+										@click.prevent="reportComment(idx)">
+										Report
+									</a>
+								</span>
+							</p>
+
+							<template v-if="post.reply_count">
+								<div v-if="!post.replies.replies_show && commentReplyIndex !== idx" class="media-body-show-replies">
+									<a href="#" class="font-weight-bold primary" @click.prevent="showCommentReplies(idx)">
+										<i class="media-body-show-replies-icon"></i>
+										<span class="media-body-show-replies-label">Show {{ prettyCount(post.reply_count) }} replies</span>
+									</a>
+								</div>
+
+								<div v-else class="media-body-show-replies">
+									<a href="#" class="font-weight-bold text-muted" @click.prevent="hideCommentReplies(idx)">
+										<i class="media-body-show-replies-icon"></i>
+										<span class="media-body-show-replies-label">Hide {{ prettyCount(post.reply_count) }} replies</span>
+									</a>
+								</div>
+							</template>
+
+							<comment-replies
+                                :key="`cmr-${post.id}-${feed[idx].reply_count}`"
+								v-if="feed[idx].replies_show"
+								:status="post"
+								:feed="feed[idx].replies"
+								v-on:counter-change="replyCounterChange(idx, $event)"
+								class="mt-3" />
+
+							<div
+								v-if="post.replies_show == true && commentReplyIndex == idx && feed[idx].reply_count > 3">
+								<div class="media-body-show-replies mt-n3">
+									<a href="#" class="font-weight-bold text-dark" @click.prevent="goToPost(post)">
+										<i class="media-body-show-replies-icon"></i>
+										<span class="media-body-show-replies-label">View full thread</span>
+									</a>
+								</div>
+							</div>
+
+							<comment-reply-form
+								v-if="commentReplyIndex == idx"
+								:parent-id="post.id"
+								v-on:new-comment="pushCommentReply(idx, $event)"
+								v-on:counter-change="replyCounterChange(idx, $event)" />
+
+							<!-- <div v-if="commentReplyIndex != undefined && commentReplyIndex == idx" class="d-flex align-items-top reply-form child-reply-form my-3">
+								<img class="shadow-sm media-avatar border" :src="profile.avatar" width="40" height="40">
+
+								<input
+									class="form-control bg-light rounded-pill shadow-sm" style="border-color: #e2e8f0 !important;"
+									placeholder="Write a comment...."
+									v-model="replyContent"
+									v-on:keyup.enter="storeComment"
+									:disabled="isPostingReply" />
+
+								<div class="reply-form-input-actions">
+									<button
+										class="btn btn-link text-muted px-1 mr-2">
+										<i class="far fa-image fa-lg"></i>
+									</button>
+									<button
+										class="btn btn-link text-muted px-1 small font-weight-bold py-0 rounded-pill text-decoration-none"
+										@click="toggleShowReplyOptions">
+										<i class="far fa-ellipsis-h"></i>
+									</button>
+								</div>
+							</div> -->
+						</div>
+					</div>
+				</transition-group>
+			</div>
+		</div>
+
+		<div v-if="!feedLoading && canLoadMore" class="post-comment-drawer-loadmore">
+			<p>
+				<a class="font-weight-bold text-dark" href="#" @click.prevent="fetchMore()">Load more comments…</a>
+			</p>
+		</div>
+
+		<div v-if="showEmptyRepliesRefresh" class="post-comment-drawer-loadmore">
+			<p class="text-center mb-4">
+				<a class="btn btn-outline-primary font-weight-bold rounded-pill" href="#" @click.prevent="forceRefresh()">
+					<i class="far fa-sync mr-2"></i> Refresh
+				</a>
+			</p>
+		</div>
+
+		<div class="d-flex align-items-top reply-form child-reply-form">
+			<img class="shadow-sm media-avatar border" :src="profile.avatar" width="40" height="40" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">
+
+			<div v-show="!settings.expanded" class="w-100">
+				<vue-tribute :options="tributeSettings">
+					<textarea
+                        class="form-control bg-light rounded-sm shadow-sm rounded-pill"
+                        placeholder="Write a comment...."
+                        style="resize: none;padding-right:140px;"
+                        rows="1"
+                        v-model="replyContent"
+                        :disabled="isPostingReply"></textarea>
+				</vue-tribute>
+			</div>
+
+			<div v-show="settings.expanded" class="w-100">
+				<vue-tribute :options="tributeSettings">
+					<textarea
+						class="form-control bg-light rounded-sm shadow-sm"
+						placeholder="Write a comment...."
+						style="resize: none;padding-right:140px;"
+						rows="5"
+						v-model="replyContent"
+						:disabled="isPostingReply"></textarea>
+				</vue-tribute>
+			</div>
+
+			<div class="reply-form-input-actions" :class="{ open: settings.expanded }">
+				<button
+					@click="replyUpload()"
+					class="btn btn-link text-muted px-1 mr-2">
+					<i class="far fa-image fa-lg"></i>
+				</button>
+				<button
+					@click="toggleReplyExpand()"
+					class="btn btn-link text-muted px-1 mr-2">
+					<i class="far fa-text-size fa-lg"></i>
+				</button>
+				<button
+					class="btn btn-link text-muted px-1 small font-weight-bold py-0 rounded-pill text-decoration-none"
+					@click="toggleShowReplyOptions">
+					<i class="far fa-ellipsis-h"></i>
+				</button>
+			</div>
+		</div>
+
+		<div v-if="showReplyOptions" class="child-reply-form-options mt-2" style="margin-left: 60px;">
+			<b-form-checkbox v-model="settings.sensitive" switch>
+				{{ $t('common.sensitive') }}
+			</b-form-checkbox>
+		</div>
+
+		<div v-if="replyContent && replyContent.length" class="text-right mt-2">
+			<button class="btn btn-primary btn-sm font-weight-bold primary rounded-pill px-4" @click="storeComment">{{ $t('common.comment') }}</button>
+		</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 position-relative"
+			>
+            <div v-if="lightboxStatus && lightboxStatus.type == 'image'" @click="hideLightbox">
+				<img :src="lightboxStatus.url" style="width: 100%;max-height: 90vh;object-fit: contain;">
+			</div>
+			<div v-else-if="lightboxStatus && lightboxStatus.type == 'video'" style="position: relative" class="d-flex align-items-center justify-content-center">
+                <button
+                    class="btn btn-dark d-flex align-items-center justify-content-center"
+                    style="position: fixed; top: 10px; right: 10px;width: 56px; height: 56px; border-radius: 56px;"
+                    @click="hideLightbox">
+                    <i class="far fa-times-circle fa-2x text-warning" style="padding-top:2px"></i>
+                </button>
+                <video :src="lightboxStatus.url" controls style="max-height: 90vh;object-fit: contain;" autoplay @ended="hideLightbox"/>
+            </div>
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import VueTribute from 'vue-tribute'
+	import ReadMore from './ReadMore.vue';
+	import ProfileHoverCard from './../profile/ProfileHoverCard.vue';
+	import CommentReplies from './CommentReplies.vue';
+	import CommentReplyForm from './CommentReplyForm.vue';
+
+	export default {
+		props: {
+			status: {
+				type: Object
+			}
+		},
+
+		components: {
+			VueTribute,
+			ReadMore,
+			ProfileHoverCard,
+			CommentReplyForm,
+			CommentReplies
+		},
+
+		data() {
+			return {
+				profile: window._sharedData.user,
+				ids: [],
+				feed: [],
+				sortIndex: 0,
+				sorts: [
+					'all',
+					'newest',
+					'popular'
+				],
+				replyContent: undefined,
+				nextUrl: undefined,
+				canLoadMore: false,
+				isPostingReply: false,
+				showReplyOptions: false,
+				feedLoading: false,
+				isUploading: false,
+				uploadProgress: 0,
+				lightboxStatus: null,
+				settings: {
+					expanded: false,
+					sensitive: false
+				},
+				tributeSettings: {
+					noMatchTemplate: null,
+					collection: [
+						{
+							trigger: '@',
+							menuShowMinLength: 2,
+							values: (function (text, cb) {
+								let url = '/api/compose/v0/search/mention';
+								axios.get(url, { params: { q: text }})
+								.then(res => {
+									cb(res.data);
+								})
+								.catch(err => {
+									cb();
+									console.log(err);
+								})
+							})
+						},
+						{
+							trigger: '#',
+							menuShowMinLength: 2,
+							values: (function (text, cb) {
+								let url = '/api/compose/v0/search/hashtag';
+								axios.get(url, { params: { q: text }})
+								.then(res => {
+									cb(res.data);
+								})
+								.catch(err => {
+									cb();
+									console.log(err);
+								})
+							})
+						}
+					]
+				},
+				showEmptyRepliesRefresh: false,
+				commentReplyIndex: undefined,
+                deletingIndex: undefined
+			}
+		},
+
+		mounted() {
+			// if(this.status.replies && this.status.replies.length) {
+			// 	this.feed.push(this.status.replies);
+			// }
+			this.fetchContext();
+		},
+
+		computed: {
+			hideCounts: {
+				get() {
+					return this.$store.state.hideCounts == true;
+				}
+			},
+		},
+
+		methods: {
+			fetchContext() {
+				axios.get('/api/v2/statuses/' + this.status.id + '/replies', {
+					params: {
+						limit: 3
+					}
+				})
+				.then(res => {
+					if(res.data.next) {
+						this.nextUrl = res.data.next;
+						this.canLoadMore = true;
+					}
+					res.data.data.forEach(post => {
+						this.ids.push(post.id);
+						this.feed.push(post);
+					});
+
+					if(!res.data || !res.data.data || !res.data.data.length && this.status.reply_count) {
+						this.showEmptyRepliesRefresh = true;
+					}
+				})
+			},
+
+			fetchMore(limit = 3) {
+                if(event) {
+                    event.target?.blur();
+                }
+                if(!this.nextUrl) {
+                    return;
+                }
+				axios.get(this.nextUrl, {
+					params: {
+						limit: limit,
+						sort: this.sorts[this.sortIndex]
+					}
+				}).then(res => {
+					this.feedLoading = false;
+					if(!res.data.next) {
+						this.canLoadMore = false;
+					}
+
+					this.nextUrl = res.data.next;
+
+					res.data.data.forEach(post => {
+						if(this.ids && this.ids.indexOf(post.id) == -1) {
+							this.ids.push(post.id);
+							this.feed.push(post);
+						}
+					});
+				})
+			},
+
+			fetchSortedFeed() {
+				axios.get('/api/v2/statuses/' + this.status.id + '/replies', {
+					params: {
+						limit: 3,
+						sort: this.sorts[this.sortIndex]
+					}
+				})
+				.then(res => {
+					this.feed = res.data.data;
+					this.nextUrl = res.data.next;
+					this.feedLoading = false;
+				});
+			},
+
+			forceRefresh() {
+				axios.get('/api/v2/statuses/' + this.status.id + '/replies', {
+					params: {
+						limit: 3,
+						refresh_cache: true
+					}
+				})
+				.then(res => {
+					if(res.data.next) {
+						this.nextUrl = res.data.next;
+						this.canLoadMore = true;
+					}
+					res.data.data.forEach(post => {
+						this.ids.push(post.id);
+						this.feed.push(post);
+					});
+
+					this.showEmptyRepliesRefresh = false;
+				})
+			},
+
+			timeago(ts) {
+				return App.util.format.timeAgo(ts);
+			},
+
+			prettyCount(val) {
+				return App.util.format.count(val);
+			},
+
+			goToPost(post) {
+				this.$router.push({
+					name: 'post',
+					path: `/i/web/post/${post.id}`,
+					params: {
+						id: post.id,
+						cachedStatus: post,
+						cachedProfile: this.profile
+					}
+				})
+			},
+
+			goToProfile(account) {
+				this.$router.push({
+					name: 'profile',
+					path: `/i/web/profile/${account.id}`,
+					params: {
+						id: account.id,
+						cachedProfile: account,
+						cachedUser: this.profile
+					}
+				})
+			},
+
+			storeComment() {
+				this.isPostingReply = true;
+
+				axios.post('/api/v1/statuses', {
+					status: this.replyContent,
+					in_reply_to_id: this.status.id,
+					sensitive: this.settings.sensitive
+				})
+				.then(res => {
+                    let cmt = res.data;
+                    cmt.replies = [];
+					this.replyContent = undefined;
+					this.isPostingReply = false;
+					this.ids.push(res.data.id);
+					this.feed.push(cmt);
+					this.$emit('counter-change', 'comment-increment');
+				})
+			},
+
+			toggleSort(index) {
+				this.$refs.sortMenu.hide();
+				this.feedLoading = true;
+				this.sortIndex = index;
+				this.fetchSortedFeed();
+			},
+
+			deleteComment(index) {
+				event.currentTarget.blur();
+
+				if(!window.confirm(this.$t('menu.deletePostConfirm'))) {
+					return;
+				}
+
+                this.deletingIndex = index;
+
+				axios.post('/i/delete', {
+					type: 'status',
+					item: this.feed[index].id
+				})
+				.then(res => {
+                    if(this.ids && this.ids.length) {
+                        this.ids.splice(index, 1);
+                    }
+                    if(this.feed && this.feed.length) {
+					   this.feed.splice(index, 1);
+                    }
+					this.$emit('counter-change', 'comment-decrement');
+				})
+                .then(() => {
+                    this.deletingIndex = undefined;
+					this.fetchMore(1);
+                })
+			},
+
+			showLikesModal(index) {
+				this.$emit('show-likes', this.feed[index]);
+			},
+
+			reportComment(index) {
+				// location.href = '/i/report?type=post&id=' + this.feed[index].id;
+				this.$emit('handle-report', this.feed[index]);
+			},
+
+			likeComment(index) {
+				event.currentTarget.blur();
+				let post = this.feed[index];
+				let count = post.favourites_count;
+				let state = post.favourited;
+				this.feed[index].favourited = !this.feed[index].favourited;
+				this.feed[index].favourites_count = state ? count - 1 : count + 1;
+
+				axios.post('/api/v1/statuses/' + post.id + '/' + (state ? 'unfavourite' : 'favourite'))
+				.then(res => {
+
+				})
+			},
+
+			toggleShowReplyOptions() {
+				event.currentTarget.blur();
+				this.showReplyOptions = !this.showReplyOptions;
+			},
+
+			replyUpload() {
+				event.currentTarget.blur();
+				this.$refs.fileInput.click();
+			},
+
+			handleImageUpload() {
+				if(!this.$refs.fileInput.files.length) {
+					return;
+				}
+
+				this.isUploading = true;
+				let self = this;
+				let data = new FormData();
+				data.append('file', this.$refs.fileInput.files[0]);
+
+				axios.post('/api/v1/media', data)
+				.then(res => {
+					axios.post('/api/v1/statuses', {
+                        status: this.replyContent,
+						media_ids: [ res.data.id ],
+						in_reply_to_id: this.status.id,
+						sensitive: this.settings.sensitive
+					}).then(res => {
+						this.feed.push(res.data);
+                        this.replyContent = undefined;
+                        this.isPostingReply = false;
+                        this.ids.push(res.data.id);
+                        this.$emit('counter-change', 'comment-increment');
+					})
+				});
+			},
+
+			lightbox(status, idx = 0) {
+				this.lightboxStatus = status.media_attachments[idx];
+				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, idx = 0) {
+				let media = status.media_attachments[idx];
+
+				if(media.preview_url.endsWith('storage/no-preview.png')) {
+					return media.url;
+				}
+
+				return media.preview_url;
+			},
+
+			toggleReplyExpand() {
+				event.currentTarget.blur();
+				this.settings.expanded = !this.settings.expanded;
+			},
+
+			toggleCommentReply(index) {
+				this.commentReplyIndex = index;
+				this.showCommentReplies(index);
+			},
+
+			showCommentReplies(index) {
+				if(this.feed[index].hasOwnProperty('replies_show') && this.feed[index].replies_show) {
+					this.feed[index].replies_show = false;
+					this.commentReplyIndex = undefined;
+					return;
+				}
+
+				this.feed[index].replies_show = true;
+				this.commentReplyIndex = index;
+				this.fetchCommentReplies(index);
+			},
+
+			hideCommentReplies(index) {
+				this.commentReplyIndex = undefined;
+				this.feed[index].replies_show = false;
+			},
+
+			fetchCommentReplies(index) {
+				axios.get('/api/v2/statuses/' + this.feed[index].id + '/replies', {
+					params: {
+						limit: 3
+					}
+				})
+				.then(res => {
+					this.feed[index].replies = res.data.data;
+				})
+			},
+
+			getPostAvatar(post) {
+				if(this.profile.id == post.account.id) {
+					return window._sharedData.user.avatar;
+				}
+
+				return post.account.avatar;
+			},
+
+			follow(index) {
+				axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/follow')
+				.then(res => {
+					this.$store.commit('updateRelationship', [res.data]);
+					this.feed[index].account.followers_count = this.feed[index].account.followers_count + 1;
+					window._sharedData.user.following_count = window._sharedData.user.following_count + 1;
+				})
+			},
+
+			unfollow(index) {
+				axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/unfollow')
+				.then(res => {
+					this.$store.commit('updateRelationship', [res.data]);
+					this.feed[index].account.followers_count = this.feed[index].account.followers_count - 1;
+					window._sharedData.user.following_count = window._sharedData.user.following_count - 1;
+				})
+			},
+
+			handleCounterChange(payload) {
+				this.$emit('counter-change', payload);
+			},
+
+			pushCommentReply(index, post) {
+				if(!this.feed[index].hasOwnProperty('replies')) {
+					this.feed[index].replies = [post];
+				} else {
+					this.feed[index].replies.push(post);
+				}
+				this.feed[index].reply_count = this.feed[index].reply_count + 1;
+                this.feed[index].replies_show = true;
+			},
+
+			replyCounterChange(index, type) {
+				switch(type) {
+					case 'comment-increment':
+						this.feed[index].reply_count = this.feed[index].reply_count + 1;
+					break;
+
+					case 'comment-decrement':
+						this.feed[index].reply_count = this.feed[index].reply_count - 1;
+					break;
+				}
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.post-comment-drawer {
+		&-feed {
+			margin-bottom: 1rem;
+
+			.sort-menu {
+				.dropdown {
+					border-radius: 18px;
+				}
+
+				.dropdown-menu {
+					padding: 0;
+				}
+
+				.dropdown-item:active {
+					background-color: inherit;
+				}
+
+				.title {
+					color: var(--dropdown-item-color);
+				}
+
+				.description {
+					margin-bottom: 0;
+					color: var(--dropdown-item-color);
+					font-size: 12px;
+				}
+
+				.active {
+					.title {
+						font-weight: 600;
+						color: var(--dropdown-item-active-color);
+					}
+
+					.description {
+						color: var(--dropdown-item-active-color);
+					}
+				}
+			}
+
+			&-loader {
+				display: flex;
+				justify-content: center;
+				align-items: center;
+				height: 200px;
+			}
+		}
+
+		.media-body {
+			&-comment {
+				position: relative;
+				min-width: 240px;
+			}
+
+			&-wrapper {
+				.media-body-comment {
+					padding: 0.7rem;
+				}
+
+				.media-body-likes-count {
+					z-index: 3;
+					position: absolute;
+					right: -5px;
+					bottom: -10px;
+					background-color: var(--body-bg);
+					padding: 1px 8px;
+					font-weight: 600;
+					font-size: 12px;
+					border-radius: 15px;
+					text-decoration: none;
+					user-select: none !important;
+
+					i {
+						margin-right: 3px;
+					}
+
+					.count {
+						color: #334155;
+					}
+				}
+			}
+
+			&-show-replies {
+				margin-top: -5px;
+				margin-bottom: 5px;
+				font-size: 13px;
+
+				a {
+					display: flex;
+					align-items: center;
+					text-decoration: none;
+				}
+
+				&-icon {
+					display: inline-block;
+					font-style: normal;
+					font-variant: normal;
+					text-rendering: auto;
+					line-height: 1;
+					padding-left: 0.5rem;
+					margin-right: 0.25rem;
+					transform: rotate(90deg);
+					font-family: 'Font Awesome 5 Free';
+					font-weight: 400;
+					text-decoration: none;
+
+					&:before {
+						content: "\F148";
+					}
+				}
+
+				&-label {
+					padding-top: 9px;
+				}
+			}
+		}
+
+		&-loadmore {
+			font-size: 0.7875rem;
+		}
+
+		.reply-form {
+			&-input {
+				flex: 1;
+				position: relative;
+
+				&-actions {
+					position: absolute;
+					right: 10px;
+					top: 50%;
+					transform: translateY(-50%);
+
+					&.open {
+						top: 85%;
+						transform: translateY(-85%);
+					}
+				}
+			}
+		}
+
+		.child-reply-form {
+			position: relative;
+		}
+
+		.bh-comment {
+			position: relative;
+			width: 100%;
+			height: auto;
+			max-width: 160px !important;
+			max-height: 260px !important;
+
+    		.img-fluid,
+			canvas {
+				border-radius: 8px;
+			}
+
+			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;
+    			border-radius: 8px;
+			}
+
+            &.bh-comment-borderless {
+                .img-fluid,
+                img,
+                canvas {
+                    border-radius: 0;
+                }
+
+                border-radius: 8px;
+                overflow: hidden;
+                margin-bottom: 5px;
+            }
+
+			.sensitive-warning {
+				position: absolute;
+				left: 50%;
+				top: 50%;
+				transform: translate(-50%, -50%);
+				text-align: center;
+				color: #fff;
+				user-select: none;
+				cursor: pointer;
+				background: rgba(0,0,0,0.4);
+				padding: 5px;
+				border-radius: 8px;
+			}
+		}
+
+		.v-tribute {
+			width: 100%;
+		}
+	}
+</style>

+ 470 - 0
resources/assets/components/partials/post/CommentReplies.vue

@@ -0,0 +1,470 @@
+<template>
+	<div class="comment-replies-component">
+		<div v-if="loading" class="mt-n2">
+			<div class="ph-item border-0 mb-0 p-0 bg-transparent" style="border-radius:15px;margin-left:-14px;">
+				<div class="ph-col-12 mb-0">
+					<div class="ph-row align-items-center mt-0">
+						<div class="ph-avatar mr-3 d-flex" style="min-width: 40px;width:40px!important;height:40px!important;border-radius: 8px;"></div>
+						<div class="ph-col-6"></div>
+					</div>
+				</div>
+			</div>
+		</div>
+		<template v-else>
+			<transition-group tag="div" enter-active-class="animate__animated animate__fadeIn" leave-active-class="animate__animated animate__fadeOut" mode="out-in">
+				<div
+					v-for="(post, idx) in feed"
+					:key="'cd:' + post.id + ':' + idx">
+					<div class="media media-status align-items-top mb-3">
+						<a href="#l">
+							<img class="shadow-sm media-avatar border" :src="post.account.avatar" width="40" height="40" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">
+						</a>
+
+						<div class="media-body">
+							<div class="media-body-wrapper">
+								<div v-if="!post.media_attachments.length" class="media-body-comment">
+									<p class="media-body-comment-username">
+										<a :href="post.account.url" @click.prevent="goToProfile(post.account)">
+											{{ post.account.acct }}
+										</a>
+									</p>
+
+									<span v-if="post.sensitive">
+										<p class="mb-0">
+											{{ $t('common.sensitiveContentWarning') }}
+										</p>
+										<a href="#" class="small font-weight-bold primary" @click.prevent="post.sensitive = false">Show</a>
+									</span>
+
+									<!-- <span v-else v-html="post.content"></span> -->
+									<read-more v-else :status="post" />
+
+									<button
+										v-if="post.favourites_count"
+										class="btn btn-link media-body-likes-count shadow-sm"
+										@click.prevent="showLikesModal(idx)">
+										<i class="far fa-thumbs-up primary"></i>
+										<span class="count">{{ prettyCount(post.favourites_count) }}</span>
+									</button>
+								</div>
+
+								<div v-else>
+									<p class="media-body-comment-username">
+										<a :href="post.account.url" @click.prevent="goToProfile(post.account)">
+											{{ post.account.acct }}
+										</a>
+									</p>
+									<div v-if="post.sensitive" class="bh-comment" @click="post.sensitive = false">
+										<blur-hash-image
+											:width="blurhashWidth(post)"
+											:height="blurhashHeight(post)"
+											:punch="1"
+											class="img-fluid border shadow"
+											:hash="post.media_attachments[0].blurhash"
+											 />
+
+										<div class="sensitive-warning">
+											<p class="mb-0"><i class="far fa-eye-slash fa-lg"></i></p>
+											<p class="mb-0 small">Click to view</p>
+										</div>
+									</div>
+
+									<div v-else class="bh-comment">
+										<div @click="lightbox(post)">
+											<blur-hash-image
+												:width="blurhashWidth(post)"
+												:height="blurhashHeight(post)"
+												:punch="1"
+												class="img-fluid border shadow"
+												:hash="post.media_attachments[0].blurhash"
+												:src="getMediaSource(post)"
+											 />
+										</div>
+
+										<button
+											v-if="post.favourites_count"
+											class="btn btn-link media-body-likes-count shadow-sm"
+											@click.prevent="showLikesModal(idx)">
+											<i class="far fa-thumbs-up primary"></i>
+											<span class="count">{{ prettyCount(post.favourites_count) }}</span>
+										</button>
+									</div>
+								</div>
+							</div>
+
+							<p class="media-body-reactions">
+								<button
+									class="btn btn-link font-weight-bold btn-sm p-0"
+									:class="[ post.favourited ? 'primary' : 'text-muted' ]"
+									@click="likeComment(idx)">
+									{{ post.favourited ? 'Liked' : 'Like' }}
+								</button>
+								<!-- <span class="mx-1">·</span>
+								<a class="font-weight-bold text-muted" :href="post.url" @click.prevent="toggleCommentReply(idx)">
+									Reply
+								</a> -->
+								<span class="mx-1">·</span>
+								<a class="font-weight-bold text-muted" :href="post.url" @click.prevent="goToPost(post)" v-once>
+									{{ timeago(post.created_at) }}
+								</a>
+								<span v-if="profile && post.account.id === profile.id">
+									<span class="mx-1">·</span>
+									<a
+										class="font-weight-bold text-muted"
+										href="#"
+										@click.prevent="deleteComment(idx)">
+										Delete
+									</a>
+								</span>
+
+								<span v-else>
+									<span class="mx-1">·</span>
+									<a
+										class="font-weight-bold text-muted"
+										href="#"
+										@click.prevent="reportComment(idx)">
+										Report
+									</a>
+								</span>
+							</p>
+
+							<!-- <div class="d-flex align-items-top reply-form child-reply-form my-3">
+								<img class="shadow-sm media-avatar border" :src="profile.avatar" width="40" height="40">
+
+								<input
+									class="form-control bg-light rounded-pill shadow-sm" style="border-color: #e2e8f0 !important;"
+									placeholder="Write a comment...."
+									v-model="replyContent"
+									v-on:keyup.enter="storeComment"
+									:disabled="isPostingReply" />
+
+								<div class="reply-form-input-actions">
+									<button
+										class="btn btn-link text-muted px-1 mr-2">
+										<i class="far fa-image fa-lg"></i>
+									</button>
+									<button
+										class="btn btn-link text-muted px-1 small font-weight-bold py-0 rounded-pill text-decoration-none"
+										@click="toggleShowReplyOptions">
+										<i class="far fa-ellipsis-h"></i>
+									</button>
+								</div>
+							</div> -->
+						</div>
+					</div>
+				</div>
+			</transition-group>
+		</template>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import ReadMore from './ReadMore.vue';
+
+	export default {
+		props: {
+			status: {
+				type: Object
+			},
+
+			feed: {
+				type: Array
+			}
+		},
+
+		components: {
+			ReadMore
+		},
+
+		data() {
+			return {
+				loading: true,
+				profile: window._sharedData.user,
+				ids: [],
+				nextUrl: undefined,
+				canLoadMore: false,
+			}
+		},
+
+		watch: {
+			feed: {
+				deep: true,
+				immediate: true,
+				handler(o, n) {
+					this.loading = false;
+				}
+			}
+		},
+
+		methods: {
+			fetchContext() {
+				axios.get('/api/v2/statuses/' + this.status.id + '/replies', {
+					params: {
+						limit: 3
+					}
+				})
+				.then(res => {
+					if(res.data.next) {
+						this.nextUrl = res.data.next;
+						this.canLoadMore = true;
+					}
+					res.data.data.forEach(post => {
+						this.ids.push(post.id);
+						this.feed.push(post);
+					});
+
+					if(!res.data || !res.data.data || !res.data.data.length && this.status.reply_count) {
+						this.showEmptyRepliesRefresh = true;
+					}
+				})
+			},
+
+			fetchMore(limit = 3) {
+				axios.get(this.nextUrl, {
+					params: {
+						limit: limit,
+						sort: this.sorts[this.sortIndex]
+					}
+				}).then(res => {
+					this.feedLoading = false;
+					if(!res.data.next) {
+						this.canLoadMore = false;
+					}
+
+					this.nextUrl = res.data.next;
+
+					res.data.data.forEach(post => {
+						if(this.ids.indexOf(post.id) == -1) {
+							this.ids.push(post.id);
+							this.feed.push(post);
+						}
+					});
+				})
+			},
+
+			fetchSortedFeed() {
+				axios.get('/api/v2/statuses/' + this.status.id + '/replies', {
+					params: {
+						limit: 3,
+						sort: this.sorts[this.sortIndex]
+					}
+				})
+				.then(res => {
+					this.feed = res.data.data;
+					this.nextUrl = res.data.next;
+					this.feedLoading = false;
+				});
+			},
+
+			forceRefresh() {
+				axios.get('/api/v2/statuses/' + this.status.id + '/replies', {
+					params: {
+						limit: 3,
+						refresh_cache: true
+					}
+				})
+				.then(res => {
+					if(res.data.next) {
+						this.nextUrl = res.data.next;
+						this.canLoadMore = true;
+					}
+					res.data.data.forEach(post => {
+						this.ids.push(post.id);
+						this.feed.push(post);
+					});
+
+					this.showEmptyRepliesRefresh = false;
+				})
+			},
+
+			timeago(ts) {
+				return App.util.format.timeAgo(ts);
+			},
+
+			prettyCount(val) {
+				return App.util.format.count(val);
+			},
+
+			goToPost(post) {
+				this.$router.push({
+					name: 'post',
+					path: `/i/web/post/${post.id}`,
+					params: {
+						id: post.id,
+						cachedStatus: post,
+						cachedProfile: this.profile
+					}
+				})
+			},
+
+			goToProfile(account) {
+				this.$router.push({
+					name: 'profile',
+					path: `/i/web/profile/${account.id}`,
+					params: {
+						id: account.id,
+						cachedProfile: account,
+						cachedUser: this.profile
+					}
+				})
+			},
+
+			storeComment() {
+				this.isPostingReply = true;
+
+				axios.post('/api/v1/statuses', {
+					status: this.replyContent,
+					in_reply_to_id: this.status.id,
+					sensitive: this.settings.sensitive
+				})
+				.then(res => {
+					this.replyContent = undefined;
+					this.isPostingReply = false;
+					this.ids.push(res.data.id);
+					this.feed.push(res.data);
+                    this.$emit('new-comment', res.data);
+				})
+			},
+
+			toggleSort(index) {
+				this.$refs.sortMenu.hide();
+				this.feedLoading = true;
+				this.sortIndex = index;
+				this.fetchSortedFeed();
+			},
+
+			deleteComment(index) {
+				event.currentTarget.blur();
+
+				if(!window.confirm(this.$t('menu.deletePostConfirm'))) {
+					return;
+				}
+
+				axios.post('/i/delete', {
+					type: 'status',
+					item: this.feed[index].id
+				})
+				.then(res => {
+					this.feed.splice(index, 1);
+					this.$emit('counter-change', 'comment-decrement');
+					this.fetchMore(1);
+				})
+				.catch(err => {
+
+				})
+			},
+
+			showLikesModal(index) {
+				this.$emit('show-likes', this.feed[index]);
+			},
+
+			reportComment(index) {
+				// location.href = '/i/report?type=post&id=' + this.feed[index].id;
+				this.$emit('handle-report', this.feed[index]);
+			},
+
+			likeComment(index) {
+				event.currentTarget.blur();
+				let post = this.feed[index];
+				let count = post.favourites_count;
+				let state = post.favourited;
+				this.feed[index].favourited = !this.feed[index].favourited;
+				this.feed[index].favourites_count = state ? count - 1 : count + 1;
+
+				axios.post('/api/v1/statuses/' + post.id + '/' + (state ? 'unfavourite' : 'favourite'))
+				.then(res => {
+
+				})
+			},
+
+			toggleShowReplyOptions() {
+				event.currentTarget.blur();
+				this.showReplyOptions = !this.showReplyOptions;
+			},
+
+			replyUpload() {
+				event.currentTarget.blur();
+				this.$refs.fileInput.click();
+			},
+
+			handleImageUpload() {
+				if(!this.$refs.fileInput.files.length) {
+					return;
+				}
+
+				this.isUploading = true;
+				let self = this;
+				let data = new FormData();
+				data.append('file', this.$refs.fileInput.files[0]);
+
+				axios.post('/api/v1/media', data)
+				.then(res => {
+					axios.post('/api/v1/statuses', {
+						media_ids: [ res.data.id ],
+						in_reply_to_id: this.status.id,
+						sensitive: this.settings.sensitive
+					}).then(res => {
+						this.feed.push(res.data)
+					})
+				});
+			},
+
+			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;
+			},
+
+			toggleReplyExpand() {
+				event.currentTarget.blur();
+				this.settings.expanded = !this.settings.expanded;
+			},
+
+			toggleCommentReply(index) {
+				this.commentReplyIndex = index;
+			}
+		}
+	}
+</script>

+ 64 - 0
resources/assets/components/partials/post/CommentReplyForm.vue

@@ -0,0 +1,64 @@
+<template>
+    <div class="my-3">
+    	<div class="d-flex align-items-top reply-form child-reply-form">
+    		<img class="shadow-sm media-avatar border" :src="profile.avatar" width="40" height="40" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">
+
+            <div style="display: flex;flex-grow: 1;position: relative;">
+        		<textarea
+        			class="form-control bg-light rounded-lg shadow-sm" style="resize: none;padding-right: 60px;"
+        			placeholder="Write a comment...."
+        			v-model="replyContent"
+        			:disabled="isPostingReply" />
+
+                <button
+                    class="btn btn-sm py-1 font-weight-bold ml-1 rounded-pill"
+                    :class="[replyContent && replyContent.length ? 'btn-primary' : 'btn-outline-muted']"
+                    @click="storeComment"
+                    :disabled="!replyContent || !replyContent.length"
+                    style="position: absolute;right:10px;top:50%;transform:translateY(-50%)">
+                    Post
+                </button>
+            </div>
+    	</div>
+        <p class="text-right small font-weight-bold text-lighter">{{ replyContent ? replyContent.length : 0 }}/{{ config.uploader.max_caption_length }}</p>
+    </div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			parentId: {
+				type: String
+			}
+		},
+
+		data() {
+			return {
+                config: App.config,
+				isPostingReply: false,
+				replyContent: '',
+				profile: window._sharedData.user,
+				sensitive: false
+			}
+		},
+
+		methods: {
+			storeComment() {
+				this.isPostingReply = true;
+
+				axios.post('/api/v1/statuses', {
+					status: this.replyContent,
+					in_reply_to_id: this.parentId,
+					sensitive: this.sensitive
+				})
+				.then(res => {
+					this.replyContent = undefined;
+					this.isPostingReply = false;
+					this.$emit('new-comment', res.data);
+					// this.ids.push(res.data.id);
+					// this.feed.push(res.data);
+				})
+			},
+		}
+	}
+</script>

+ 803 - 0
resources/assets/components/partials/post/ContextMenu.vue

@@ -0,0 +1,803 @@
+<template>
+	<div class="modal-stack">
+		<b-modal ref="ctxModal"
+			id="ctx-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+
+			<div class="list-group text-center">
+				<div
+					v-if="status.visibility !== 'archived'"
+					class="list-group-item rounded cursor-pointer font-weight-bold"
+					@click="ctxMenuGoToPost()">
+					{{ $t('menu.viewPost') }}
+				</div>
+
+				<div
+					v-if="status.visibility !== 'archived'"
+					class="list-group-item rounded cursor-pointer font-weight-bold"
+					@click="ctxMenuGoToProfile()">
+					{{ $t('menu.viewProfile') }}
+				</div>
+
+				<div
+					v-if="status.visibility !== 'archived'"
+					class="list-group-item rounded cursor-pointer font-weight-bold"
+					@click="ctxMenuShare()">
+					{{ $t('common.share') }}
+				</div>
+
+				<div
+					v-if="status && profile && profile.is_admin == true && status.visibility !== 'archived'"
+					class="list-group-item rounded cursor-pointer font-weight-bold"
+					@click="ctxModMenuShow()">
+					{{ $t('menu.moderationTools') }}
+				</div>
+
+				<div
+					v-if="status && status.account.id != profile.id"
+					class="list-group-item rounded cursor-pointer text-danger font-weight-bold"
+					@click="ctxMenuReportPost()">
+					{{ $t('menu.report') }}
+				</div>
+
+				<div
+					v-if="status && profile.id == status.account.id && status.visibility !== 'archived'"
+					class="list-group-item rounded cursor-pointer text-danger font-weight-bold"
+					@click="archivePost(status)">
+					{{ $t('menu.archive') }}
+				</div>
+
+				<div
+					v-if="status && profile.id == status.account.id && status.visibility == 'archived'"
+					class="list-group-item rounded cursor-pointer text-danger font-weight-bold"
+					@click="unarchivePost(status)">
+					{{ $t('menu.unarchive') }}
+				</div>
+
+				<div
+					v-if="config.ab.pue && status && profile.id == status.account.id && status.visibility !== 'archived'"
+					class="list-group-item rounded cursor-pointer text-danger font-weight-bold"
+					@click="editPost(status)">
+					Edit
+				</div>
+
+				<div
+					v-if="status && (profile.is_admin || profile.id == status.account.id) && status.visibility !== 'archived'"
+					class="list-group-item rounded cursor-pointer text-danger font-weight-bold"
+					@click="deletePost(status)">
+                    <div v-if="isDeleting" class="spinner-border spinner-border-sm" role="status">
+                        <span class="sr-only">Loading...</span>
+                    </div>
+                    <div v-else>
+					   {{ $t('common.delete') }}
+                    </div>
+				</div>
+
+				<div
+					class="list-group-item rounded cursor-pointer text-lighter font-weight-bold"
+					@click="closeCtxMenu()">
+					{{ $t('common.cancel') }}
+				</div>
+			</div>
+		</b-modal>
+
+		<b-modal ref="ctxModModal"
+			id="ctx-mod-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+
+			<div class="list-group text-center">
+				<p class="py-2 px-3 mb-0">
+					<div
+						class="text-center font-weight-bold text-danger">
+						{{ $t('menu.moderationTools') }}
+					</div>
+
+					<div class="small text-center text-muted">
+						{{ $t('menu.selectOneOption') }}
+					</div>
+				</p>
+
+				<div
+					class="list-group-item rounded cursor-pointer"
+					@click="moderatePost(status, 'unlist')">
+					{{ $t('menu.unlistFromTimelines') }}
+				</div>
+
+				<div
+					v-if="status.sensitive"
+					class="list-group-item rounded cursor-pointer"
+					@click="moderatePost(status, 'remcw')">
+					{{ $t('menu.removeCW') }}
+				</div>
+
+				<div
+					v-else
+					class="list-group-item rounded cursor-pointer"
+					@click="moderatePost(status, 'addcw')">
+					{{ $t('menu.addCW') }}
+				</div>
+
+				<div
+					class="list-group-item rounded cursor-pointer"
+					@click="moderatePost(status, 'spammer')">
+					{{ $t('menu.markAsSpammer') }}<br />
+					<span class="small">{{ $t('menu.markAsSpammerText') }}</span>
+				</div>
+
+				<div
+					class="list-group-item rounded cursor-pointer text-lighter"
+					@click="ctxModMenuClose()">
+					{{ $t('common.cancel') }}
+				</div>
+			</div>
+		</b-modal>
+
+		<b-modal ref="ctxModOtherModal"
+			id="ctx-mod-other-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="list-group text-center">
+				<p class="py-2 px-3 mb-0">
+					<div class="text-center font-weight-bold text-danger">{{ $t('menu.moderationTools') }}</div>
+					<div class="small text-center text-muted">{{ $t('menu.selectOneOption') }}</div>
+				</p>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="confirmModal()">Unlist Posts</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="confirmModal()">Moderation Log</div>
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxModOtherMenuClose()">{{ $t('common.cancel') }}</div>
+			</div>
+		</b-modal>
+
+		<b-modal ref="ctxShareModal"
+			id="ctx-share-modal"
+			title="Share"
+			hide-footer
+			hide-header
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded text-center">
+			<div class="list-group-item rounded cursor-pointer" @click="shareStatus(status, $event)">{{status.reblogged ? 'Unshare' : 'Share'}} {{ $t('menu.toFollowers') }}</div>
+			<div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">{{ $t('common.copyLink') }}</div>
+			<div v-if="status && status.local == true && !status.in_reply_to_id" class="list-group-item rounded cursor-pointer" @click="ctxMenuEmbed()">{{ $t('menu.embed') }}</div>
+			<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxShareMenu()">{{ $t('common.cancel') }}</div>
+		</b-modal>
+
+		<b-modal ref="ctxEmbedModal"
+			id="ctx-embed-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="md"
+			body-class="p-2 rounded">
+			<div>
+				<div class="form-group">
+					<textarea class="form-control disabled text-monospace" rows="8" style="overflow-y:hidden;border: 1px solid #efefef; font-size: 12px; line-height: 18px; margin: 0 0 7px;resize:none;" v-model="ctxEmbedPayload" disabled=""></textarea>
+				</div>
+				<div class="form-group pl-2 d-flex justify-content-center">
+					<div class="form-check mr-3">
+						<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowCaption" :disabled="ctxEmbedCompactMode == true">
+						<label class="form-check-label font-weight-light">
+							{{ $t('menu.showCaption') }}
+						</label>
+					</div>
+					<div class="form-check mr-3">
+						<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowLikes" :disabled="ctxEmbedCompactMode == true">
+						<label class="form-check-label font-weight-light">
+							{{ $t('menu.showLikes') }}
+						</label>
+					</div>
+					<div class="form-check">
+						<input class="form-check-input" type="checkbox" v-model="ctxEmbedCompactMode">
+						<label class="form-check-label font-weight-light">
+							{{ $t('menu.compactMode') }}
+						</label>
+					</div>
+				</div>
+				<hr>
+				<button :class="copiedEmbed ? 'btn btn-primary btn-block btn-sm py-1 font-weight-bold disabed': 'btn btn-primary btn-block btn-sm py-1 font-weight-bold'" @click="ctxCopyEmbed" :disabled="copiedEmbed">{{copiedEmbed ? 'Embed Code Copied!' : 'Copy Embed Code'}}</button>
+				<p class="mb-0 px-2 small text-muted">{{ $t('menu.embedConfirmText') }} <a href="/site/terms">{{ $t('site.terms') }}</a></p>
+			</div>
+		</b-modal>
+
+		<b-modal ref="ctxReport"
+			id="ctx-report"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<p class="py-2 px-3 mb-0">
+				<div class="text-center font-weight-bold text-danger">{{ $t('menu.report') }}</div>
+				<div class="small text-center text-muted">{{ $t('menu.selectOneOption') }}</div>
+			</p>
+			<div class="list-group text-center">
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('spam')">{{ $t('menu.spam') }}</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('sensitive')">{{ $t('menu.sensitive') }}</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('abusive')">{{ $t('menu.abusive') }}</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="openCtxReportOtherMenu()">{{ $t('common.other') }}</div>
+				<!-- <div class="list-group-item rounded cursor-pointer" @click="ctxReportMenuGoBack()">Go Back</div> -->
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxReportMenuGoBack()">{{ $t('common.cancel') }}</div>
+			</div>
+		</b-modal>
+
+		<b-modal ref="ctxReportOther"
+			id="ctx-report-other"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<p class="py-2 px-3 mb-0">
+				<div class="text-center font-weight-bold text-danger">{{ $t('menu.report') }}</div>
+				<div class="small text-center text-muted">{{ $t('menu.selectOneOption') }}</div>
+			</p>
+			<div class="list-group text-center">
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('underage')">{{ $t('menu.underageAccount') }}</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('copyright')">{{ $t('menu.copyrightInfringement') }}</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('impersonation')">{{ $t('menu.impersonation') }}</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('scam')">{{ $t('menu.scamOrFraud') }}</div>
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxReportOtherMenuGoBack()">{{ $t('common.cancel') }}</div>
+			</div>
+		</b-modal>
+
+		<b-modal ref="ctxConfirm"
+			id="ctx-confirm"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="d-flex align-items-center justify-content-center py-3">
+				<div>{{ this.confirmModalTitle }}</div>
+			</div>
+			<div class="d-flex border-top btn-group btn-group-block rounded-0" role="group">
+				<button type="button" class="btn btn-outline-lighter border-left-0 border-top-0 border-bottom-0 border-right py-2" style="color: rgb(0,122,255) !important;" @click.prevent="confirmModalCancel()">{{ $t('common.cancel') }}</button>
+				<button type="button" class="btn btn-outline-lighter border-0" style="color: rgb(0,122,255) !important;" @click.prevent="confirmModalConfirm()">Confirm</button>
+			</div>
+		</b-modal>
+
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: [
+			'status',
+			'profile'
+		],
+
+		data() {
+			return {
+				config: window.App.config,
+				ctxMenuStatus: false,
+				ctxMenuRelationship: false,
+				ctxEmbedPayload: false,
+				copiedEmbed: false,
+				replySending: false,
+				ctxEmbedShowCaption: true,
+				ctxEmbedShowLikes: false,
+				ctxEmbedCompactMode: false,
+				confirmModalTitle: 'Are you sure?',
+				confirmModalIdentifer: null,
+				confirmModalType: false,
+                isDeleting: false
+			}
+		},
+
+		watch: {
+			ctxEmbedShowCaption: function (n,o) {
+				if(n == true) {
+					this.ctxEmbedCompactMode = false;
+				}
+				let mode = this.ctxEmbedCompactMode ? 'compact' : 'full';
+				this.ctxEmbedPayload = window.App.util.embed.post(this.ctxMenuStatus.url, this.ctxEmbedShowCaption, this.ctxEmbedShowLikes, mode);
+			},
+			ctxEmbedShowLikes: function (n,o) {
+				if(n == true) {
+					this.ctxEmbedCompactMode = false;
+				}
+				let mode = this.ctxEmbedCompactMode ? 'compact' : 'full';
+				this.ctxEmbedPayload = window.App.util.embed.post(this.ctxMenuStatus.url, this.ctxEmbedShowCaption, this.ctxEmbedShowLikes, mode);
+			},
+			ctxEmbedCompactMode: function (n,o) {
+				if(n == true) {
+					this.ctxEmbedShowCaption = false;
+					this.ctxEmbedShowLikes = false;
+				}
+				let mode = this.ctxEmbedCompactMode ? 'compact' : 'full';
+				this.ctxEmbedPayload = window.App.util.embed.post(this.ctxMenuStatus.url, this.ctxEmbedShowCaption, this.ctxEmbedShowLikes, mode);
+			}
+		},
+
+		methods: {
+			open() {
+				this.ctxMenu();
+			},
+
+			openModMenu() {
+				this.$refs.ctxModModal.show();
+			},
+
+			ctxMenu() {
+				this.ctxMenuStatus = this.status;
+				this.ctxEmbedPayload = window.App.util.embed.post(this.status.url);
+				// if(this.status.account.id == this.profile.id) {
+					this.ctxMenuRelationship = false;
+					this.$refs.ctxModal.show();
+				// } else {
+				// 	axios.get('/api/pixelfed/v1/accounts/relationships', {
+				// 		params: {
+				// 			'id[]': this.status.account.id
+				// 		}
+				// 	}).then(res => {
+				// 		this.ctxMenuRelationship = res.data[0];
+				// 		this.$refs.ctxModal.show();
+				// 	});
+				// }
+			},
+
+			closeCtxMenu() {
+				this.copiedEmbed = false;
+				this.ctxMenuStatus = false;
+				this.ctxMenuRelationship = false;
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxReport.hide();
+				this.$refs.ctxReportOther.hide();
+				this.closeModals();
+			},
+
+			ctxMenuCopyLink() {
+				let status = this.ctxMenuStatus;
+				navigator.clipboard.writeText(status.url);
+				this.closeModals();
+				return;
+			},
+
+			ctxMenuGoToPost() {
+				let status = this.ctxMenuStatus;
+				this.statusUrl(status);
+				this.closeCtxMenu();
+				return;
+			},
+
+			ctxMenuGoToProfile() {
+				let status = this.ctxMenuStatus;
+				this.profileUrl(status);
+				this.closeCtxMenu();
+				return;
+			},
+
+			ctxMenuReportPost() {
+				this.$refs.ctxModal.hide();
+				// this.$refs.ctxReport.show();
+				this.$emit('report-modal', this.ctxMenuStatus);
+				return;
+			},
+
+			ctxMenuEmbed() {
+				this.closeModals();
+				this.$refs.ctxEmbedModal.show();
+			},
+
+			ctxMenuShare() {
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxShareModal.show();
+			},
+
+			closeCtxShareMenu() {
+				this.$refs.ctxShareModal.hide();
+				this.$refs.ctxModal.show();
+			},
+
+			ctxCopyEmbed() {
+				navigator.clipboard.writeText(this.ctxEmbedPayload);
+				this.ctxEmbedShowCaption = true;
+				this.ctxEmbedShowLikes = false;
+				this.ctxEmbedCompactMode = false;
+				this.$refs.ctxEmbedModal.hide();
+			},
+
+			ctxModMenuShow() {
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxModModal.show();
+			},
+
+			ctxModOtherMenuShow() {
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxModModal.hide();
+				this.$refs.ctxModOtherModal.show();
+			},
+
+			ctxModMenu() {
+				this.$refs.ctxModal.hide();
+			},
+
+			ctxModMenuClose() {
+				this.closeModals();
+			},
+
+			ctxModOtherMenuClose() {
+				this.closeModals();
+				this.$refs.ctxModModal.show();
+			},
+
+			formatCount(count) {
+				return App.util.format.count(count);
+			},
+
+			openCtxReportOtherMenu() {
+				let s = this.ctxMenuStatus;
+				this.closeCtxMenu();
+				this.ctxMenuStatus = s;
+				this.$refs.ctxReportOther.show();
+			},
+
+			ctxReportMenuGoBack() {
+				this.$refs.ctxReportOther.hide();
+				this.$refs.ctxReport.hide();
+				this.$refs.ctxModal.show();
+			},
+
+			ctxReportOtherMenuGoBack() {
+				this.$refs.ctxReportOther.hide();
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxReport.show();
+			},
+
+			sendReport(type) {
+				let id = this.ctxMenuStatus.id;
+
+				swal({
+					'title': this.$t('menu.confirmReport'),
+					'text': this.$t('menu.confirmReportText'),
+					'icon': 'warning',
+					'buttons': true,
+					'dangerMode': true
+				}).then((res) => {
+					if(res) {
+						axios.post('/i/report/', {
+							'report': type,
+							'type': 'post',
+							'id': id,
+						}).then(res => {
+							this.closeCtxMenu();
+							swal(this.$t('menu.reportSent'), this.$t('menu.reportSentText'), 'success');
+						}).catch(err => {
+							swal(this.$t('common.oops'), this.$t('menu.reportSentError'), 'error');
+						})
+					} else {
+						this.closeCtxMenu();
+					}
+				});
+			},
+
+			closeModals() {
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxModModal.hide();
+				this.$refs.ctxModOtherModal.hide();
+				this.$refs.ctxShareModal.hide();
+				this.$refs.ctxEmbedModal.hide();
+				this.$refs.ctxReport.hide();
+				this.$refs.ctxReportOther.hide();
+				this.$refs.ctxConfirm.hide();
+			},
+
+			openCtxStatusModal() {
+				this.closeModals();
+				this.$refs.ctxStatusModal.show();
+			},
+
+			openConfirmModal() {
+				this.closeModals();
+				this.$refs.ctxConfirm.show();
+			},
+
+			closeConfirmModal() {
+				this.closeModals();
+				this.confirmModalTitle = 'Are you sure?';
+				this.confirmModalType = false;
+				this.confirmModalIdentifer = null;
+			},
+
+			confirmModalConfirm() {
+				switch(this.confirmModalType) {
+					case 'post.delete':
+						axios.post('/i/delete', {
+							type: 'status',
+							item: this.confirmModalIdentifer
+						}).then(res => {
+							this.feed = this.feed.filter(s => {
+								return s.id != this.confirmModalIdentifer;
+							});
+							this.closeConfirmModal();
+						}).catch(err => {
+							this.closeConfirmModal();
+							swal(this.$t('common.error'), this.$t('common.errorMsg'), 'error');
+						});
+					break;
+				}
+
+				this.closeConfirmModal();
+			},
+
+			confirmModalCancel() {
+				this.closeConfirmModal();
+			},
+
+			moderatePost(status, action, $event) {
+				let username = status.account.username;
+				let pid = status.id;
+				let msg = '';
+				let self = this;
+				switch(action) {
+					case 'addcw':
+						msg = this.$t('menu.modAddCWConfirm');
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									swal(this.$t('common.success'), this.$t('menu.modCWSuccess'), 'success');
+									// status.sensitive = true;
+									this.$emit('moderate', 'addcw');
+									self.closeModals();
+									self.ctxModMenuClose();
+								}).catch(err => {
+									self.closeModals();
+									self.ctxModMenuClose();
+									swal(this.$t('common.error'), this.$t('common.errorMsg'), 'error');
+								});
+							}
+						});
+					break;
+
+					case 'remcw':
+						msg = this.$t('menu.modRemoveCWConfirm');
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									swal(this.$t('common.success'), this.$t('menu.modRemoveCWSuccess'), 'success');
+									// status.sensitive = false;
+									this.$emit('moderate', 'remcw');
+									self.closeModals();
+									self.ctxModMenuClose();
+								}).catch(err => {
+									self.closeModals();
+									self.ctxModMenuClose();
+									swal(this.$t('common.error'), this.$t('common.errorMsg'), 'error');
+								});
+							}
+						});
+					break;
+
+					case 'unlist':
+						msg = this.$t('menu.modUnlistConfirm');
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									// this.feed = this.feed.filter(f => {
+									// 	return f.id != status.id;
+									// });
+									this.$emit('moderate', 'unlist');
+									swal(this.$t('common.success'), this.$t('menu.modUnlistSuccess'), 'success');
+									self.closeModals();
+									self.ctxModMenuClose();
+								}).catch(err => {
+									self.closeModals();
+									self.ctxModMenuClose();
+									swal(this.$t('common.error'), this.$t('common.errorMsg'), 'error');
+								});
+							}
+						});
+					break;
+
+					case 'spammer':
+						msg = this.$t('menu.modMarkAsSpammerConfirm');
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									this.$emit('moderate', 'spammer');
+									swal(this.$t('common.success'), this.$t('menu.modMarkAsSpammerSuccess'), 'success');
+									self.closeModals();
+									self.ctxModMenuClose();
+								}).catch(err => {
+									self.closeModals();
+									self.ctxModMenuClose();
+									swal(this.$t('common.error'), this.$t('common.errorMsg'), 'error');
+								});
+							}
+						});
+					break;
+				}
+			},
+
+			shareStatus(status, $event) {
+				if($('body').hasClass('loggedIn') == false) {
+					return;
+				}
+
+				this.closeModals();
+
+				axios.post('/i/share', {
+					item: status.id
+				}).then(res => {
+					status.reblogs_count = res.data.count;
+					status.reblogged = !status.reblogged;
+					// if(status.reblogged) {
+					// 	swal('Success', 'You shared this post', 'success');
+					// } else {
+					// 	swal('Success', 'You unshared this post', 'success');
+					// }
+				}).catch(err => {
+					swal(this.$t('common.error'), this.$t('common.errorMsg'), 'error');
+				});
+			},
+
+			statusUrl(status) {
+				if(status.account.local == true) {
+					this.$router.push({
+						name: 'post',
+						path: `/i/web/post/${status.id}`,
+						params: {
+							id: status.id,
+							cachedStatus: status,
+							cachedProfile: this.profile
+						}
+					});
+					return;
+				}
+
+				let permalink = this.$route.params.hasOwnProperty('id');
+				if(permalink) {
+					location.href = status.url;
+					return;
+				} else {
+					this.$router.push({
+						name: 'post',
+						path: `/i/web/post/${status.id}`,
+						params: {
+							id: status.id,
+							cachedStatus: status,
+							cachedProfile: this.profile
+						}
+					});
+					return;
+				}
+			},
+
+			profileUrl(status) {
+				this.$router.push({
+					name: 'profile',
+					path: `/i/web/profile/${status.account.id}`,
+					params: {
+						id: status.account.id,
+						cachedProfile: status.account,
+						cachedUser: this.profile
+					}
+				});
+				return;
+			},
+
+			deletePost(status) {
+                this.isDeleting = true;
+
+				if(this.ownerOrAdmin(status) == false) {
+					return;
+				}
+
+				if(window.confirm(this.$t('menu.deletePostConfirm')) == false) {
+					return;
+				}
+
+				axios.post('/i/delete', {
+					type: 'status',
+					item: status.id
+				}).then(res => {
+					this.$emit('delete');
+					this.closeModals();
+                    this.isDeleting = false;
+				}).catch(err => {
+					swal(this.$t('common.error'), this.$t('common.errorMsg'), 'error');
+				});
+			},
+
+			owner(status) {
+				return this.profile.id === status.account.id;
+			},
+
+			admin() {
+				return this.profile.is_admin == true;
+			},
+
+			ownerOrAdmin(status) {
+				return this.owner(status) || this.admin();
+			},
+
+			archivePost(status) {
+				if(window.confirm(this.$t('menu.archivePostConfirm')) == false) {
+					return;
+				}
+
+				axios.post('/api/pixelfed/v2/status/' + status.id + '/archive')
+				.then(res => {
+					this.$emit('status-delete', status.id);
+					this.$emit('archived', status.id);
+					this.closeModals();
+				});
+			},
+
+			unarchivePost(status) {
+				if(window.confirm(this.$t('menu.unarchivePostConfirm')) == false) {
+					return;
+				}
+
+				axios.post('/api/pixelfed/v2/status/' + status.id + '/unarchive')
+				.then(res => {
+					this.$emit('unarchived', status.id);
+					this.closeModals();
+				});
+			},
+
+			editPost(status) {
+				this.closeModals();
+				this.$emit('edit', status);
+			}
+		}
+	}
+</script>

+ 31 - 0
resources/assets/components/partials/post/EditHistoryModal.vue

@@ -123,6 +123,7 @@
 					</template>
 					<div class="w-100 my-4 px-4 text-break justify-content-start">
 						<p class="mb-0" v-html="allHistory[historyIndex].content"></p>
+						<!-- <p class="mb-0" v-html="getDiff(historyIndex)"></p> -->
 					</div>
 				</div>
 			</template>
@@ -170,6 +171,36 @@
 				})
 			},
 
+			getDiff(idx) {
+				if(idx == this.allHistory.length - 1) {
+					return this.allHistory[this.allHistory.length - 1].content;
+				}
+
+				// let r = Diff.diffChars(this.allHistory[idx - 1].content.replace(/(<([^>]+)>)/gi, ""), this.allHistory[idx].content.replace(/(<([^>]+)>)/gi, ""));
+				let fragment = document.createElement('div');
+				r.forEach((part) => {
+					  // green for additions, red for deletions
+					  // grey for common parts
+					  const color = part.added ? 'green' :
+					    part.removed ? 'red' : 'grey';
+					  let span = document.createElement('span');
+					  span.style.color = color;
+					  console.log(part.value, part.value.length)
+					  if(part.added) {
+					  	let trimmed = part.value.trim();
+					  	if(!trimmed.length) {
+						  span.appendChild(document.createTextNode('·'));
+					  	} else {
+						  span.appendChild(document.createTextNode(part.value));
+					  	}
+					  } else {
+						  span.appendChild(document.createTextNode(part.value));
+					  }
+					  fragment.appendChild(span);
+				});
+				return fragment.innerHTML;
+			},
+
 			formatTime(ts) {
 				let date = Date.parse(ts);
 				let seconds = Math.floor((new Date() - date) / 1000);

+ 15 - 0
resources/assets/components/partials/post/LikeListPlaceholder.vue

@@ -0,0 +1,15 @@
+<template>
+	<div class="list-group-item border-left-0 border-right-0 px-3">
+		<div class="ph-item border-0 p-0 m-0 align-items-center">
+			<div class="p-0 mb-0" style="flex: unset">
+				<div class="ph-avatar" style="min-width: 40px !important;width:40px !important;height:40px;"></div>
+			</div>
+			<div class="ph-col-9 mb-0">
+				<div class="ph-row">
+					<div class="ph-col-12"></div>
+					<div class="ph-col-12"></div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>

+ 243 - 0
resources/assets/components/partials/post/MediaContainer.vue

@@ -0,0 +1,243 @@
+<template>
+	<div class="feed-media-container bg-black">
+		<div class="text-muted" style="max-height: 400px;">
+			<div>
+				<div v-if="post.pf_type === 'photo'">
+					<div v-if="post.sensitive == true" class="content-label-wrapper">
+						<div class="text-light content-label">
+							<p class="text-center">
+								<i class="far fa-eye-slash fa-2x"></i>
+							</p>
+							<p class="h4 font-weight-bold text-center">
+								{{ $t('common.sensitiveContent') }}
+							</p>
+							<p class="text-center py-2 content-label-text">
+								{{ post.spoiler_text ? post.spoiler_text : $t('common.sensitiveContentWarning') }}
+							</p>
+							<p class="mb-0">
+								<button class="btn btn-outline-light btn-block btn-sm font-weight-bold" @click="toggleContentWarning()">See Post</button>
+							</p>
+						</div>
+
+						<blur-hash-image
+						width="32"
+						height="32"
+						:punch="1"
+						class="blurhash-wrapper"
+						:hash="post.media_attachments[0].blurhash"
+						/>
+					</div>
+
+					<div v-else class="content-label-wrapper">
+						<blur-hash-image
+							:key="key"
+							width="32"
+							height="32"
+							:punch="1"
+							:hash="post.media_attachments[0].blurhash"
+							:src="post.media_attachments[0].url"
+							class="blurhash-wrapper"
+							/>
+
+						<p v-if="!post.sensitive && sensitive"
+							@click="post.sensitive = true"
+							style="
+							margin-top: 0;
+							padding: 10px;
+							color: #000;
+							font-size: 10px;
+							text-align: right;
+							position: absolute;
+							top: 0;
+							right: 0;
+							border-radius: 11px;
+							cursor: pointer;
+							background: rgba(255, 255, 255,.5);
+							">
+						<i class="fas fa-eye-slash fa-lg"></i>
+						</p>
+					</div>
+				</div>
+
+				<!-- <div v-else-if="post.pf_type === 'photo:album'">
+					<img :src="media[mediaIndex].url" style="width: 100%;height: 500px;object-fit: contain;">
+
+					<div class="d-flex mt-3 justify-content-center">
+						<div
+							v-for="(thumb, index) in media"
+							class="mr-2 border rounded p-1"
+							:class="[ index === mediaIndex ? 'border-light' : 'border-dark' ]"
+							@click="mediaIndex = index">
+							<img :src="thumb.preview_url" width="60" height="40" style="object-fit:cover;">
+						</div>
+					</div>
+				</div> -->
+				<!-- <photo-album-presenter :status="post" v-on:togglecw="post.sensitive = false"/> -->
+
+				<!-- <video-presenter v-else-if="post.pf_type === 'video'" :status="post" v-on:togglecw="post.sensitive = false" /> -->
+
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			post: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			},
+
+			user: {
+				type: Object
+			},
+
+			media: {
+				type: Array
+			},
+
+			showArrows: {
+				type: Boolean,
+				default: true
+			}
+		},
+
+		data() {
+			return {
+				loading: false,
+				shortcuts: undefined,
+				sensitive: false,
+				mediaIndex: 0
+			}
+		},
+
+		mounted() {
+			this.initShortcuts();
+		},
+
+		beforeDestroy() {
+			document.removeEventListener('keyup', this.shortcuts);
+		},
+
+		methods: {
+			navPrev() {
+				// event.currentTarget.blur();
+				if(this.mediaIndex == 0) {
+					this.loading = true;
+					axios.get('/api/v1/accounts/' + this.profile.id + '/statuses', {
+						params: {
+							limit: 1,
+							max_id: this.post.id
+						}
+					}).then(res => {
+						if(!res.data.length) {
+							this.mediaIndex = this.media.length - 1;
+							this.loading = false;
+							return;
+						}
+						this.$emit('navigate', res.data[0]);
+						this.mediaIndex = 0;
+						// this.post = res.data[0];
+						// this.media = this.post.media_attachments;
+						// this.fetchState(this.post.account.username, this.post.id);
+						// this.loading = false;
+						let url = window.location.origin + `/@${this.post.account.username}/post/${this.post.id}`;
+						history.pushState(null, null, url);
+					}).catch(err => {
+						this.mediaIndex = this.media.length - 1;
+						this.loading = false;
+					});
+					return;
+				}
+				this.mediaIndex--;
+			},
+
+			navNext() {
+				// event.currentTarget.blur();
+				if(this.mediaIndex == this.media.length - 1) {
+					this.loading = true;
+					axios.get('/api/v1/accounts/' + this.profile.id + '/statuses', {
+						params: {
+							limit: 1,
+							min_id: this.post.id
+						}
+					}).then(res => {
+						if(!res.data.length) {
+							this.mediaIndex = 0;
+							this.loading = false;
+							return;
+						}
+						this.$emit('navigate', res.data[0]);
+						this.mediaIndex = 0;
+						// this.post = res.data[0];
+						// this.media = this.post.media_attachments;
+						// this.fetchState(this.post.account.username, this.post.id);
+						// this.loading = false;
+						let url = window.location.origin + `/@${this.post.account.username}/post/${this.post.id}`;
+						history.pushState(null, null, url);
+					}).catch(err => {
+						this.mediaIndex = 0;
+						this.loading = false;
+					});
+					return;
+				}
+				this.mediaIndex++;
+			},
+
+			initShortcuts() {
+				this.shortcuts = document.addEventListener('keyup', event => {
+					if (event.key === 'ArrowLeft') {
+						this.navPrev();
+					}
+
+					if (event.key === 'ArrowRight') {
+						this.navNext();
+					}
+				});
+			},
+		}
+	}
+</script>
+
+<style lang="scss">
+	.feed-media-container {
+
+		.blurhash-wrapper {
+			img {
+				border-radius:15px;
+				max-height: 400px;
+				object-fit: contain;
+				background-color: #000;
+			}
+
+			canvas {
+				border-radius: 15px;
+				max-height: 400px;
+			}
+		}
+
+		.content-label-wrapper {
+			position: relative;
+		}
+
+		.content-label {
+			margin: 0;
+			position: absolute;
+			top:0;
+			left:0;
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+			justify-content: center;
+			width: 100%;
+			height: 400px;
+			z-index: 2;
+			border-radius: 15px;
+			background: rgba(0, 0, 0, 0.2)
+		}
+	}
+</style>

+ 222 - 0
resources/assets/components/partials/post/PostContent.vue

@@ -0,0 +1,222 @@
+<template>
+	<div class="timeline-status-component-content">
+		<div v-if="status.pf_type === 'poll'" class="postPresenterContainer" style="background: #000;">
+		</div>
+
+		<div v-else-if="!fixedHeight" class="postPresenterContainer" style="background: #000;">
+			<div v-if="status.pf_type === 'photo'" class="w-100">
+				<photo-presenter
+					:status="status"
+					v-on:lightbox="toggleLightbox"
+					v-on:togglecw="status.sensitive = false"/>
+			</div>
+
+			<div v-else-if="status.pf_type === 'video'" class="w-100">
+				<video-presenter :status="status" v-on:togglecw="status.sensitive = false"></video-presenter>
+			</div>
+
+			<div v-else-if="status.pf_type === 'photo:album'" class="w-100">
+				<photo-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="status.sensitive = false"></photo-album-presenter>
+			</div>
+
+			<div v-else-if="status.pf_type === 'video:album'" class="w-100">
+				<video-album-presenter :status="status" v-on:togglecw="status.sensitive = false"></video-album-presenter>
+			</div>
+
+			<div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
+				<mixed-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="status.sensitive = false"></mixed-album-presenter>
+			</div>
+		</div>
+
+		<div v-else class="card-body p-0">
+			<div v-if="status.pf_type === 'photo'" :class="{ fixedHeight: fixedHeight }">
+				<div v-if="status.sensitive == true" class="content-label-wrapper">
+					<div class="text-light content-label">
+						<p class="text-center">
+							<i class="far fa-eye-slash fa-2x"></i>
+						</p>
+						<p class="h4 font-weight-bold text-center">
+							{{ $t('common.sensitiveContent') }}
+						</p>
+						<p class="text-center py-2 content-label-text">
+							{{ status.spoiler_text ? status.spoiler_text : $t('common.sensitiveContentWarning') }}
+						</p>
+						<p class="mb-0">
+							<button class="btn btn-outline-light btn-block btn-sm font-weight-bold" @click="toggleContentWarning()">See Post</button>
+						</p>
+					</div>
+
+					<blur-hash-image
+						width="32"
+						height="32"
+						:punch="1"
+						class="blurhash-wrapper"
+						:hash="status.media_attachments[0].blurhash"
+						/>
+				</div>
+				<div
+					v-else
+					@click.prevent="toggleLightbox"
+					class="content-label-wrapper"
+					style="position: relative;width:100%;height: 400px;overflow: hidden;z-index:1"
+					>
+
+					<img
+                        :src="status.media_attachments[0].url"
+                        style="position: absolute;width: 105%;height: 410px;object-fit: cover;z-index: 1;top:0;left:0;filter: brightness(0.35) blur(6px);margin:-5px;">
+
+					<!-- <blur-hash-canvas
+						v-if="status.media_attachments[0].blurhash && status.media_attachments[0].blurhash != 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay'"
+						:key="key"
+						width="32"
+						height="32"
+						:punch="1"
+						:hash="status.media_attachments[0].blurhash"
+						style="position: absolute;width: 105%;height: 410px;object-fit: cover;z-index: 1;top:0;left:0;filter: brightness(0.35);"
+						/> -->
+
+					<blur-hash-image
+						:key="key"
+						width="32"
+						height="32"
+						:punch="1"
+						:hash="status.media_attachments[0].blurhash"
+						:src="status.media_attachments[0].url"
+						class="blurhash-wrapper"
+                        :alt="status.media_attachments[0].description"
+                        :title="status.media_attachments[0].description"
+						style="width: 100%;position: absolute;z-index:9;top:0:left:0"
+						/>
+
+					<p v-if="!status.sensitive && sensitive"
+						@click="status.sensitive = true"
+						style="
+						margin-top: 0;
+						padding: 10px;
+						color: #000;
+						font-size: 10px;
+						text-align: right;
+						position: absolute;
+						top: 0;
+						right: 0;
+						border-radius: 11px;
+						cursor: pointer;
+						background: rgba(255, 255, 255,.5);
+					">
+						<i class="fas fa-eye-slash fa-lg"></i>
+					</p>
+				</div>
+			</div>
+
+			<template v-else-if="status.pf_type === 'video'">
+				<div v-if="status.sensitive == true" class="content-label-wrapper">
+					<div class="text-light content-label">
+						<p class="text-center">
+							<i class="far fa-eye-slash fa-2x"></i>
+						</p>
+						<p class="h4 font-weight-bold text-center">
+							Sensitive Content
+						</p>
+						<p class="text-center py-2 content-label-text">
+							{{ status.spoiler_text ? status.spoiler_text : 'This post may contain sensitive content.'}}
+						</p>
+						<p class="mb-0">
+							<button @click="status.sensitive = false" class="btn btn-outline-light btn-block btn-sm font-weight-bold">See Post</button>
+						</p>
+					</div>
+				</div>
+				<video v-else class="card-img-top shadow" :class="{ fixedHeight: fixedHeight }" style="border-radius:15px;object-fit: contain;background-color: #000;" controls :poster="getPoster(status)">
+					<source :src="status.media_attachments[0].url" :type="status.media_attachments[0].mime">
+				</video>
+			</template>
+
+			<div v-else-if="status.pf_type === 'photo:album'" class="card-img-top shadow" style="border-radius: 15px;">
+				<photo-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="toggleContentWarning()" style="border-radius:15px !important;object-fit: contain;background-color: #000;overflow: hidden;" :class="{ fixedHeight: fixedHeight }"/>
+			</div>
+
+			<div v-else-if="status.pf_type === 'photo:video:album'" class="card-img-top shadow" style="border-radius: 15px;">
+				<mixed-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="status.sensitive = false" style="border-radius:15px !important;object-fit: contain;background-color: #000;overflow: hidden;align-items:center" :class="{ fixedHeight: fixedHeight }"></mixed-album-presenter>
+			</div>
+
+			<div v-else-if="status.pf_type === 'text'"></div>
+
+			<div v-else class="bg-light rounded-lg d-flex align-items-center justify-content-center" style="height: 400px;">
+				<div>
+					<p class="text-center">
+						<i class="fas fa-exclamation-triangle fa-4x"></i>
+					</p>
+
+					<p class="lead text-center mb-0">
+						Cannot display post
+					</p>
+
+					<p class="small text-center mb-0">
+						<!-- <a class="font-weight-bold primary" href="#">Report issue</a> -->
+						{{status.pf_type}}:{{status.id}}
+					</p>
+				</div>
+			</div>
+		</div>
+
+		<div
+			v-if="status.content && !status.sensitive"
+			class="card-body status-text"
+			:class="[ status.pf_type === 'text' ? 'py-0' : 'pb-0']">
+			<p>
+				<read-more :status="status" :cursor-limit="300"/>
+			</p>
+			<!-- <p v-html="status.content_text || status.content">
+			</p> -->
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import BigPicture from 'bigpicture';
+	import ReadMore from './ReadMore.vue';
+
+	export default {
+		props: ['status'],
+
+		components: {
+			"read-more": ReadMore,
+		},
+
+		data() {
+			return {
+				key: 1,
+				sensitive: false,
+			};
+		},
+
+		computed: {
+			fixedHeight: {
+				get() {
+					return this.$store.state.fixedHeight == true;
+				}
+			}
+		},
+
+		methods: {
+			toggleLightbox(e) {
+				BigPicture({
+					el: e.target
+				})
+			},
+
+			toggleContentWarning() {
+				this.key++;
+				this.sensitive = true;
+				this.status.sensitive = !this.status.sensitive;
+			},
+
+            getPoster(status) {
+                let url = status.media_attachments[0].preview_url;
+                if(url.endsWith('no-preview.jpg') || url.endsWith('no-preview.png')) {
+                    return;
+                }
+                return url;
+            }
+		}
+	}
+</script>

+ 592 - 0
resources/assets/components/partials/post/PostEditModal.vue

@@ -0,0 +1,592 @@
+<template>
+	<b-modal
+		centered
+		v-model="isOpen"
+		body-class="p-0"
+		footer-class="d-flex justify-content-between align-items-center">
+		<template #modal-header="{ close }">
+			<div class="d-flex flex-grow-1 justify-content-between align-items-center">
+				<span style="width:40px;"></span>
+				<h5 class="font-weight-bold mb-0">Edit Post</h5>
+				<b-button size="sm" variant="link" @click="close()">
+					<i class="far fa-times text-dark fa-lg"></i>
+				</b-button>
+			</div>
+		</template>
+
+		<b-card
+			v-if="isLoading"
+			no-body
+			flush
+			class="shadow-none p-0">
+			<b-card-body style="min-height:300px" class="d-flex align-items-center justify-content-center">
+				<div class="d-flex justify-content-center align-items-center flex-column" style="gap: 0.4rem;">
+					<b-spinner variant="primary" />
+					<p class="small mb-0 font-weight-lighter">Loading Post...</p>
+				</div>
+			</b-card-body>
+		</b-card>
+
+		<b-card
+			v-else-if="!isLoading && isOpen && status && status.id"
+			no-body
+			flush
+			class="shadow-none p-0">
+			<b-card-header header-tag="nav">
+				<b-nav tabs fill card-header>
+					<b-nav-item :active="tabIndex === 0" @click="toggleTab(0)">Caption</b-nav-item>
+					<b-nav-item :active="tabIndex === 1" @click="toggleTab(1)">Media</b-nav-item>
+					<!-- <b-nav-item :active="tabIndex === 2" @click="toggleTab(2)">Audience</b-nav-item> -->
+					<b-nav-item :active="tabIndex === 4" @click="toggleTab(3)">Other</b-nav-item>
+				</b-nav>
+			</b-card-header>
+			<b-card-body style="min-height:300px">
+				<template v-if="tabIndex === 0">
+					<p class="font-weight-bold small">Caption</p>
+					<div class="media mb-0">
+						<div class="media-body">
+							<div class="form-group">
+								<label class="font-weight-bold text-muted small d-none">Caption</label>
+								<vue-tribute :options="tributeSettings">
+									<textarea
+										class="form-control border-0 rounded-0 no-focus"
+										rows="4"
+										placeholder="Write a caption..."
+										v-model="fields.caption"
+										:maxlength="config.uploader.max_caption_length"
+										v-on:keyup="composeTextLength = fields.caption.length"></textarea>
+								</vue-tribute>
+								<p class="help-text small text-right text-muted mb-0">{{composeTextLength}}/{{config.uploader.max_caption_length}}</p>
+							</div>
+						</div>
+					</div>
+
+					<hr />
+
+					<p class="font-weight-bold small">Sensitive/NSFW</p>
+					<div class="border py-2 px-3 bg-light rounded">
+						<b-form-checkbox v-model="fields.sensitive" name="check-button" switch style="font-weight:300">
+							<span class="ml-1 small">Contains spoilers, sensitive or nsfw content</span>
+						</b-form-checkbox>
+					</div>
+					<transition name="slide-fade">
+						<div v-if="fields.sensitive" class="form-group mt-3">
+							<label class="font-weight-bold small">Content Warning</label>
+							<textarea
+								class="form-control"
+								rows="2"
+								placeholder="Add an optional spoiler/content warning..."
+								:maxlength="140"
+								v-model="fields.spoiler_text"></textarea>
+							<p class="help-text small text-right text-muted mb-0">{{fields.spoiler_text ? fields.spoiler_text.length : 0}}/140</p>
+						</div>
+					</transition>
+				</template>
+
+				<template v-else-if="tabIndex === 1">
+					<div class="list-group">
+						<div
+							class="list-group-item"
+							v-for="(media, idx) in fields.media"
+							:key="'edm:' + media.id + ':' + idx">
+							<div class="d-flex justify-content-between align-items-center">
+								<template v-if="media.type === 'image'">
+									<img
+										:src="media.url"
+										width="40"
+										height="40"
+										style="object-fit: cover;"
+										class="bg-light rounded cursor-pointer"
+										@click="toggleLightbox"
+										/>
+								</template>
+
+								<p class="d-none d-lg-block mb-0"><span class="small font-weight-light">{{ media.mime }}</span></p>
+
+								<button
+									class="btn btn-sm font-weight-bold rounded-pill px-4"
+									style="font-size: 13px"
+									:class="[ media.description && media.description.length ? 'btn-success' : 'btn-outline-muted']"
+									@click.prevent="handleAddAltText(idx)"
+									>
+									{{ media.description && media.description.length ? 'Edit Alt Text' : 'Add Alt Text' }}
+								</button>
+
+								<div v-if="fields.media && fields.media.length > 1" class="btn-group">
+									<a
+										class="btn btn-outline-secondary btn-sm"
+										href="#"
+										:disabled="idx === 0"
+										:class="{ disabled: idx === 0}"
+										@click.prevent="toggleMediaOrder('prev', idx)">
+										<i class="fas fa-arrow-alt-up"></i>
+									</a>
+									<a
+										class="btn btn-outline-secondary btn-sm"
+										href="#"
+										:disabled="idx === fields.media.length - 1"
+										:class="{ disabled: idx === fields.media.length - 1}"
+										@click.prevent="toggleMediaOrder('next', idx)">
+										<i class="fas fa-arrow-alt-down"></i>
+									</a>
+								</div>
+
+								<button
+									class="btn btn-outline-danger btn-sm"
+									v-if="fields.media && fields.media.length && fields.media.length > 1"
+									@click.prevent="removeMedia(idx)">
+									<i class="far fa-trash-alt"></i>
+								</button>
+							</div>
+							<transition name="slide-fade">
+								<template v-if="altTextEditIndex === idx">
+									<div class="form-group mt-1">
+										<label class="font-weight-bold small">Alt Text</label>
+										<b-form-textarea
+											v-model="media.description"
+											placeholder="Describe your image for the visually impaired..."
+											rows="3"
+											max-rows="6"
+											@input="handleAltTextUpdate(idx)"
+										></b-form-textarea>
+										<div class="d-flex justify-content-between">
+											<a class="font-weight-bold small text-muted" href="#" @click.prevent="altTextEditIndex = undefined">Close</a>
+											<p class="help-text small mb-0">
+												{{ fields.media[idx].description ? fields.media[idx].description.length : 0 }}/{{config.uploader.max_altext_length}}
+											</p>
+										</div>
+									</div>
+								</template>
+							</transition>
+						</div>
+					</div>
+				</template>
+
+				<!-- <template v-else-if="tabIndex === 2">
+					<p class="font-weight-bold small">Audience</p>
+
+					<div class="list-group">
+						<div
+							v-if="!status.account.locked"
+							class="list-group-item font-weight-bold cursor-pointer"
+							:class="{ 'text-primary': fields.visibility == 'public' }"
+							@click="toggleVisibility('public')">
+							Public
+							<i v-if="fields.visibility == 'public'" class="far fa-check-circle ml-1"></i>
+						</div>
+						<div
+							v-if="!status.account.locked"
+							class="list-group-item font-weight-bold cursor-pointer"
+							:class="{ 'text-primary': fields.visibility == 'unlisted' }"
+							@click="toggleVisibility('unlisted')">
+							Unlisted
+							<i v-if="fields.visibility == 'unlisted'" class="far fa-check-circle ml-1"></i>
+						</div>
+						<div
+							class="list-group-item font-weight-bold cursor-pointer"
+							:class="{ 'text-primary': fields.visibility == 'private' }"
+							@click="toggleVisibility('private')">
+							Followers Only
+							<i v-if="fields.visibility == 'private'" class="far fa-check-circle ml-1"></i>
+						</div>
+					</div>
+				</template> -->
+
+				<template v-else-if="tabIndex === 3">
+					<p class="font-weight-bold small">Location</p>
+					<autocomplete
+						:search="locationSearch"
+						placeholder="Search locations ..."
+						aria-label="Search locations ..."
+						:get-result-value="getResultValue"
+						@submit="onSubmitLocation"
+					>
+					</autocomplete>
+
+					<div v-if="fields.location && fields.location.hasOwnProperty('id')" class="mt-3 border rounded p-3 d-flex justify-content-between">
+						<p class="font-weight-bold mb-0">
+							{{ fields.location.name }}, {{ fields.location.country}}
+						</p>
+						<button class="btn btn-link text-danger m-0 p-0" @click.prevent="clearLocation">
+							<i class="far fa-trash"></i>
+						</button>
+					</div>
+				</template>
+			</b-card-body>
+		</b-card>
+
+		<template
+			#modal-footer="{ ok, cancel, hide }">
+			<b-button class="rounded-pill px-3 font-weight-bold" variant="outline-muted" @click="cancel()">
+				Cancel
+			</b-button>
+
+			<b-button
+				class="rounded-pill font-weight-bold"
+				variant="primary"
+				style="min-width: 195px"
+				@click="handleSave"
+				:disabled="!canSave">
+				<template v-if="isSubmitting">
+					<b-spinner small />
+				</template>
+				<template v-else>
+					Save Updates
+				</template>
+			</b-button>
+		</template>
+	</b-modal>
+</template>
+
+<script type="text/javascript">
+	import Autocomplete from '@trevoreyre/autocomplete-vue';
+	import BigPicture from 'bigpicture';
+
+	export default {
+		components: {
+			Autocomplete,
+		},
+
+		data() {
+			return {
+				config: window.App.config,
+				status: undefined,
+				isLoading: true,
+				isOpen: false,
+				isSubmitting: false,
+				tabIndex: 0,
+				canEdit: false,
+				composeTextLength: 0,
+				canSave: false,
+				originalFields: {
+					caption: undefined,
+					visibility: undefined,
+					sensitive: undefined,
+					location: undefined,
+					spoiler_text: undefined,
+					media: [],
+				},
+				fields: {
+					caption: undefined,
+					visibility: undefined,
+					sensitive: undefined,
+					location: undefined,
+					spoiler_text: undefined,
+					media: [],
+				},
+				medias: undefined,
+				altTextEditIndex: undefined,
+				tributeSettings: {
+					noMatchTemplate: function () { return null; },
+					collection: [
+						{
+							trigger: '@',
+							menuShowMinLength: 2,
+							values: (function (text, cb) {
+								let url = '/api/compose/v0/search/mention';
+								axios.get(url, { params: { q: text }})
+								.then(res => {
+									cb(res.data);
+								})
+								.catch(err => {
+									console.log(err);
+								})
+							})
+						},
+						{
+							trigger: '#',
+							menuShowMinLength: 2,
+							values: (function (text, cb) {
+								let url = '/api/compose/v0/search/hashtag';
+								axios.get(url, { params: { q: text }})
+								.then(res => {
+									cb(res.data);
+								})
+								.catch(err => {
+									console.log(err);
+								})
+							})
+						}
+					]
+				},
+			}
+		},
+
+		watch: {
+			fields: {
+				deep: true,
+				immediate: true,
+				handler: function(n, o) {
+					if(!this.canEdit) {
+						return;
+					}
+                    this.canSave = this.originalFields !== JSON.stringify(this.fields);
+                }
+			}
+		},
+
+		methods: {
+			reset() {
+				this.status = undefined;
+				this.tabIndex = 0;
+				this.isOpen = false;
+				this.canEdit = false;
+				this.composeTextLength = 0;
+				this.canSave = false;
+				this.originalFields = {
+					caption: undefined,
+					visibility: undefined,
+					sensitive: undefined,
+					location: undefined,
+					spoiler_text: undefined,
+					media: [],
+				};
+				this.fields = {
+					caption: undefined,
+					visibility: undefined,
+					sensitive: undefined,
+					location: undefined,
+					spoiler_text: undefined,
+					media: [],
+				};
+				this.medias = undefined;
+				this.altTextEditIndex = undefined;
+				this.isSubmitting = false;
+			},
+
+			async show(status) {
+				await axios.get('/api/v1/statuses/' + status.id, {
+					params: {
+						'_pe': 1
+					}
+				})
+				.then(res => {
+					this.reset();
+					this.init(res.data);
+				})
+				.finally(() => {
+					setTimeout(() => {
+						this.isLoading = false;
+					}, 500);
+				})
+			},
+
+			init(status) {
+				this.reset();
+				this.originalFields = JSON.stringify({
+					caption: status.content_text,
+					visibility: status.visibility,
+					sensitive: status.sensitive,
+					location: status.place,
+					spoiler_text: status.spoiler_text,
+					media: status.media_attachments
+				})
+				this.fields = {
+					caption: status.content_text,
+					visibility: status.visibility,
+					sensitive: status.sensitive,
+					location: status.place,
+					spoiler_text: status.spoiler_text,
+					media: status.media_attachments
+				}
+				this.status = status;
+				this.medias = status.media_attachments;
+				this.composeTextLength = status.content_text ? status.content_text.length : 0;
+				this.isOpen = true;
+				setTimeout(() => {
+					this.canEdit = true;
+				}, 1000);
+			},
+
+			toggleTab(idx) {
+				this.tabIndex = idx;
+				this.altTextEditIndex = undefined;
+			},
+
+			toggleVisibility(vis) {
+				this.fields.visibility = vis;
+			},
+
+			locationSearch(input) {
+				if (input.length < 1) { return []; }
+				let results = [];
+				return axios.get('/api/compose/v0/search/location', {
+					params: {
+						q: input
+					}
+				}).then(res => {
+					return res.data;
+				});
+			},
+
+			getResultValue(result) {
+				return result.name + ', ' + result.country
+			},
+
+			onSubmitLocation(result) {
+				this.fields.location = result;
+				this.tabIndex = 0;
+			},
+
+			clearLocation() {
+				event.currentTarget.blur();
+				this.fields.location = null;
+				this.tabIndex = 0;
+			},
+
+			handleAltTextUpdate(idx) {
+				if (this.fields.media[idx].description.length == 0) {
+					this.fields.media[idx].description = null;
+				}
+			},
+
+			moveMedia(from, to, arr) {
+				const newArr = [...arr];
+
+				const item = newArr.splice(from, 1)[0];
+				newArr.splice(to, 0, item);
+
+				return newArr;
+			},
+
+			toggleMediaOrder(dir, idx) {
+				if(dir === 'prev') {
+					this.fields.media = this.moveMedia(idx, idx - 1, this.fields.media);
+				}
+
+				if(dir === 'next') {
+					this.fields.media = this.moveMedia(idx, idx + 1, this.fields.media);
+				}
+			},
+
+			toggleLightbox(e) {
+				BigPicture({
+					el: e.target
+				})
+			},
+
+			handleAddAltText(idx) {
+				event.currentTarget.blur();
+				this.altTextEditIndex = idx
+			},
+
+			removeMedia(idx) {
+				swal({
+					title: 'Confirm',
+					text: 'Are you sure you want to remove this media from your post?',
+					buttons: {
+						cancel: "Cancel",
+						confirm: {
+							text: "Confirm Removal",
+							value: "remove",
+							className: "swal-button--danger"
+						}
+					}
+				})
+				.then((val) => {
+					if(val === 'remove') {
+						this.fields.media.splice(idx, 1);
+					}
+				})
+			},
+
+			async handleSave() {
+				event.currentTarget.blur();
+				this.canSave = false;
+				this.isSubmitting = true;
+
+				await this.checkMediaUpdates();
+
+				axios.put('/api/v1/statuses/' + this.status.id, {
+					status: this.fields.caption,
+					spoiler_text: this.fields.spoiler_text,
+					sensitive: this.fields.sensitive,
+					media_ids: this.fields.media.map(m => m.id),
+					location: this.fields.location
+				})
+				.then(res => {
+					this.isOpen = false;
+					this.$emit('update', res.data);
+					swal({
+						title: 'Post Updated',
+						text: 'You have successfully updated this post!',
+						icon: 'success',
+						buttons: {
+							close: {
+								text: "Close",
+								value: "close",
+								close: true,
+								className: "swal-button--cancel"
+							},
+							view: {
+								text: "View Post",
+								value: "view",
+								className: "btn-primary"
+							}
+						}
+					})
+					.then((val) => {
+						if(val === 'view') {
+							if(this.$router.currentRoute.name === 'post') {
+								window.location.reload();
+							} else {
+								this.$router.push('/i/web/post/' + this.status.id);
+							}
+						}
+					});
+				})
+				.catch(err => {
+					this.isSubmitting = false;
+					if(err.response.data.hasOwnProperty('error')) {
+						swal('Error', err.response.data.error, 'error');
+					} else {
+						swal('Error', 'An error occured, please try again later', 'error');
+					}
+					console.log(err);
+				})
+			},
+
+			async checkMediaUpdates() {
+				const cached = JSON.parse(this.originalFields);
+				const medias = JSON.stringify(cached.media);
+				if (medias !== JSON.stringify(this.fields.media)) {
+					await axios.all(this.fields.media.map((media) => this.updateAltText(media)))
+				}
+			},
+
+			async updateAltText(media) {
+				return await axios.put('/api/v1/media/' + media.id, {
+					description: media.description
+				});
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	div, p {
+		font-family: var(--font-family-sans-serif);
+	}
+
+	.nav-link {
+		font-size: 13px;
+		font-weight: 600;
+		color: var(--text-lighter);
+
+		&.active {
+			font-weight: 800;
+			color: var(--primary);
+		}
+	}
+
+	.slide-fade-enter-active {
+		transition: all .5s ease;
+	}
+	.slide-fade-leave-active {
+		transition: all .2s cubic-bezier(0.5, 1.0, 0.6, 1.0);
+	}
+	.slide-fade-enter, .slide-fade-leave-to {
+		transform: translateY(20px);
+		opacity: 0;
+	}
+</style>

+ 99 - 0
resources/assets/components/partials/post/ReadMore.vue

@@ -0,0 +1,99 @@
+<template>
+	<div class="read-more-component" style="word-break: break-word;">
+		<div v-html="content"></div>
+		<!-- <div v-if="status.content.length < 200" v-html="content"></div>
+		<div v-else>
+			<span v-html="content"></span>
+			<a
+				v-if="cursor == 200 || fullContent.length > cursor"
+				class="font-weight-bold text-muted" href="#"
+				style="display: block;white-space: nowrap;"
+				@click.prevent="readMore">
+				<i class="d-none fas fa-caret-down"></i> {{ $t('common.readMore') }}...
+			</a>
+		</div> -->
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			status: {
+				type: Object
+			},
+
+			cursorLimit: {
+				type: Number,
+				default: 200
+			}
+		},
+
+		data() {
+			return {
+				preRender: undefined,
+				fullContent: null,
+				content: null,
+				cursor: 200
+			}
+		},
+
+		mounted() {
+			this.rewriteLinks();
+		},
+
+		methods: {
+			readMore() {
+				this.cursor = this.cursor + 200;
+				this.content = this.fullContent.substr(0, this.cursor);
+			},
+
+			rewriteLinks() {
+				let content = this.status.content;
+				let el = document.createElement('div');
+				el.innerHTML = content;
+				el.querySelectorAll('a[class*="hashtag"]')
+				.forEach(elr => {
+					let tag = elr.innerText;
+					if(tag.substr(0, 1) == '#') {
+						tag = tag.substr(1);
+					}
+					elr.removeAttribute('target');
+					elr.setAttribute('href', '/i/web/hashtag/' + tag);
+				})
+				el.querySelectorAll('a:not(.hashtag)[class*="mention"], a:not(.hashtag)[class*="list-slug"]')
+				.forEach(elr => {
+					let name = elr.innerText;
+					if(name.substr(0, 1) == '@') {
+						name = name.substr(1);
+					}
+					if(this.status.account.local == false && !name.includes('@')) {
+						let domain = document.createElement('a');
+						domain.href = elr.getAttribute('href');
+						name = name + '@' + domain.hostname;
+					}
+					elr.removeAttribute('target');
+					elr.setAttribute('href', '/i/web/username/' + name);
+				})
+				this.content = el.outerHTML;
+
+				this.injectCustomEmoji();
+			},
+
+			injectCustomEmoji() {
+				// console.log('inecting custom emoji');
+				// let re = /:\w{1,100}:/g;
+				// let matches = this.status.content.match(re);
+				// console.log(matches);
+				// if(this.status.emojis.length == 0) {
+				// 	return;
+				// }
+				let self = this;
+				this.status.emojis.forEach(function(emoji) {
+					let img = `<img draggable="false" class="emojione custom-emoji" alt="${emoji.shortcode}" title="${emoji.shortcode}" src="${emoji.url}" data-original="${emoji.url}" data-static="${emoji.static_url}" width="18" height="18" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`;
+					self.content = self.content.replace(`:${emoji.shortcode}:`, img);
+				});
+				// this.content = this.content.replace(':fediverse:', '😅');
+			}
+		}
+	}
+</script>