1
0
Daniel Supernault 2 жил өмнө
parent
commit
1f6d11736a

+ 61 - 0
resources/assets/components/partials/BlurhashCanvas.vue

@@ -0,0 +1,61 @@
+<template>
+	<canvas ref="canvas" :width="parseNumber(width)" :height="parseNumber(height)" />
+</template>
+
+<script type="text/javascript">
+	import { decode } from 'blurhash';
+
+	export default {
+		props: {
+			hash: {
+				type: String,
+				required: true
+			},
+
+			width: {
+				type: [Number, String],
+				default: 32
+			},
+
+			height: {
+				type: [Number, String],
+				default: 32
+			},
+
+			punch: {
+				type: Number,
+				default: 1
+			}
+		},
+
+		mounted() {
+			this.draw();
+		},
+
+		updated() {
+			// this.draw();
+		},
+
+		beforeDestroy() {
+			// this.hash = null;
+			// this.$refs.canvas = null;
+		},
+
+		methods: {
+			parseNumber(val) {
+				return typeof val === 'number' ? val : parseInt(val, 10);
+			},
+
+			draw() {
+				const width = this.parseNumber(this.width);
+				const height = this.parseNumber(this.height);
+				const punch = this.parseNumber(this.punch);
+				const pixels = decode(this.hash, width, height, punch);
+				const ctx = this.$refs.canvas.getContext('2d');
+				const imageData = ctx.createImageData(width, height);
+				imageData.data.set(pixels);
+				ctx.putImageData(imageData, 0, 0);
+			},
+		}
+	}
+</script>

+ 97 - 0
resources/assets/components/partials/discover/discover-spotlight.vue

@@ -0,0 +1,97 @@
+<template>
+	<div class="discover-spotlight">
+		<div class="card bg-dark text-white">
+			<div class="card-body my-5 p-5 w-100 h-100 d-flex justify-content-center align-items-center">
+				<transition enter-active-class="animate__animated animate__fadeInDownBig" leave-active-class="animate__animated animate__fadeOutDownBig" mode="out-in">
+					<div v-if="isLoaded" class="row">
+						<div class="col-5">
+							<h1 class="display-3 font-default mb-3" style="line-height: 1;font-weight: 600;">
+								Spotlight
+							</h1>
+							<h1 class="display-5 font-default" style="line-height: 1;">
+								<span class="text-muted" style="line-height: 0.8;font-weight: 200;letter-spacing: -1.2px;">
+									Community curated
+									collection of creators
+								</span>
+							</h1>
+
+							<p class="lead font-default mt-4">This weeks collection is curated by <span class="primary">@dansup</span></p>
+						</div>
+						<div class="col-7 d-flex justify-content-between">
+							<div class="text-center mr-4">
+								<h5 class="font-default mb-2">@dansup</h5>
+								<img src="https://pixelfed.test/storage/avatars/321493203255693312/skvft7.jpg?v=33" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';" class="avatar shadow cursor-pointer" width="160" height="160">
+								<button class="btn btn-outline-light btn-sm rounded-pill py-1 mt-2 font-default">View Profile</button>
+							</div>
+
+							<div class="text-center mr-4">
+								<h5 class="font-default mb-2">@dansup</h5>
+								<img src="https://pixelfed.test/storage/avatars/321493203255693312/skvft7.jpg?v=33" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';" class="avatar shadow cursor-pointer" width="160" height="160">
+								<button class="btn btn-outline-light btn-sm rounded-pill py-1 mt-2 font-default">View Profile</button>
+							</div>
+
+							<div class="text-center">
+								<h5 class="font-default mb-2">@dansup</h5>
+								<img src="https://pixelfed.test/storage/avatars/321493203255693312/skvft7.jpg?v=33" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';" class="avatar shadow cursor-pointer" width="160" height="160">
+								<button class="btn btn-outline-light btn-sm rounded-pill py-1 mt-2 font-default">View Profile</button>
+							</div>
+						</div>
+					</div>
+				</transition>
+
+				<div v-if="!isLoaded" class="">
+					<b-spinner type="grow" />
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		data() {
+			return {
+				isLoaded: false
+			}
+		},
+
+		mounted() {
+			setTimeout(() => {
+				this.isLoaded = true;
+			}, 1000);
+		}
+	}
+</script>
+
+<style lang="scss">
+.discover-spotlight {
+	overflow: hidden;
+
+	.card-body {
+		min-height: 322px;
+	}
+
+	.font-default {
+		font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+		letter-spacing: -0.7px;
+	}
+
+	.bg-stellar {
+		background: #7474BF;
+		background: -webkit-linear-gradient(to right, #348AC7, #7474BF);
+		background: linear-gradient(to right, #348AC7, #7474BF);
+	}
+
+	.bg-berry {
+		background: #5433FF;
+		background: -webkit-linear-gradient(to right, #acb6e5, #86fde8);
+		background: linear-gradient(to right, #acb6e5, #86fde8);
+	}
+
+	.bg-midnight {
+		background: #232526;
+		background: -webkit-linear-gradient(to right, #414345, #232526);
+		background: linear-gradient(to right, #414345, #232526);
+	}
+}
+</style>

+ 54 - 0
resources/assets/components/partials/discover/news-slider.vue

@@ -0,0 +1,54 @@
+<template>
+	<div class="rounded-3 overflow-hidden discover-news-slider">
+		<div class="row align-items-center">
+			<div class="col-xl-4 col-md-5 offset-lg-1">
+				<div class="pt-5 pb-3 pb-md-5 px-4 px-lg-0">
+					<p class="lead mb-3" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;letter-spacing: -0.7px;font-weight:300;font-size:20px;">Introducing</p>
+					<h2 class="h1 pb-0 mb-3" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;letter-spacing: -1px;font-weight:700;">Emoji <span class="primary">Reactions</span></h2>
+					<p class="lead" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;letter-spacing: -0.7px;font-weight:400;font-size:17px;line-height:15px;">
+						A new way to interact with content,<br /> now available!
+					</p>
+					<a href="#" class="btn btn-primary primary btn-sm">Learn more <i class="far fa-chevron-right fa-sm ml-2"></i></a>
+				</div>
+			</div>
+			<div class="col-lg-6 col-md-7 offset-xl-1">
+				<div class="position-relative d-flex flex-column align-items-center justify-content-center h-100">
+					<svg class="d-none d-md-block position-absolute top-50 start-0 translate-middle-y" width="868" height="868" style="min-width: 868px;" viewBox="0 0 868 868" fill="none" xmlns="http://www.w3.org/2000/svg"><circle opacity="0.15" cx="434" cy="434" r="434" fill="#7dd3fc"></circle></svg>
+					<div class="d-flex">
+						<img src="/img/remoji/hushed_face.gif" class="position-relative zindex-3 mb-2 my-lg-4" width="100" alt="Illustration">
+						<img src="/img/remoji/thumbs_up.gif" class="position-relative zindex-3 mb-2 my-lg-4" width="100" alt="Illustration">
+						<img src="/img/remoji/sparkling_heart.gif" class="position-relative zindex-3 mb-2 my-lg-4" width="100" alt="Illustration">
+					</div>
+				</div>
+			</div>
+		</div>
+		<div style="position: absolute;left: 50%;transform: translateX(-50%);bottom:10px;">
+			<div class="d-flex">
+				<button class="btn btn-link p-0">
+					<i class="far fa-dot-circle"></i>
+				</button>
+
+				<button class="btn btn-link p-0 mx-2">
+					<i class="far fa-circle"></i>
+				</button>
+
+				<button class="btn btn-link p-0">
+					<i class="far fa-circle"></i>
+				</button>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+
+	}
+</script>
+
+<style lang="scss">
+	.discover-news-slider {
+		position: relative;
+		background-color: #e0f2fe;
+	}
+</style>

+ 348 - 0
resources/assets/components/partials/profile/ProfileHoverCard.vue

@@ -0,0 +1,348 @@
+<template>
+	<div class="profile-hover-card">
+		<div class="profile-hover-card-inner">
+			<div class="d-flex justify-content-between align-items-start" style="max-width: 240px;">
+				<a
+					:href="profile.url"
+					@click.prevent="goToProfile()">
+					<img
+						:src="profile.avatar"
+						width="50"
+						height="50"
+						class="avatar"
+						onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+				</a>
+
+				<div v-if="user.id == profile.id">
+					<a class="btn btn-outline-primary px-3 py-1 font-weight-bold rounded-pill" href="/settings/home">Edit Profile</a>
+				</div>
+
+				<div v-if="user.id != profile.id && relationship">
+					<button
+						v-if="relationship.following"
+						class="btn btn-outline-primary px-3 py-1 font-weight-bold rounded-pill"
+						:disabled="isLoading"
+						@click="performUnfollow()">
+							<span v-if="isLoading"><b-spinner small /></span>
+							<span v-else>Following</span>
+						</button>
+					<div v-else>
+						<button
+							v-if="!relationship.requested"
+							class="btn btn-primary primary px-3 py-1 font-weight-bold rounded-pill"
+							:disabled="isLoading"
+							@click="performFollow()">
+							<span v-if="isLoading"><b-spinner small /></span>
+							<span v-else>Follow</span>
+						</button>
+						<button v-else class="btn btn-primary primary px-3 py-1 font-weight-bold rounded-pill" disabled>Follow Requested</button>
+					</div>
+				</div>
+			</div>
+
+			<p class="display-name">
+				<a
+					:href="profile.url"
+					@click.prevent="goToProfile()"
+					v-html="getDisplayName()">
+					{{ profile.display_name ? profile.display_name : profile.username }}
+				</a>
+			</p>
+
+			<div class="username">
+				<a
+					:href="profile.url"
+					class="username-link"
+					@click.prevent="goToProfile()">
+					&commat;{{ getUsername() }}
+				</a>
+
+				<p v-if="user.id != profile.id && relationship && relationship.followed_by" class="username-follows-you">
+					<span>Follows You</span>
+				</p>
+			</div>
+
+			<p
+				v-if="profile.hasOwnProperty('pronouns') && profile.pronouns && profile.pronouns.length"
+				class="pronouns">
+				{{ profile.pronouns.join(', ') }}
+			</p>
+
+
+			<p class="bio" v-html="bio"></p>
+
+			<p class="stats">
+				<span class="stats-following">
+					<span class="following-count">{{ formatCount(profile.following_count) }}</span> Following
+				</span>
+				<span class="stats-followers">
+					<span class="followers-count">{{ formatCount(profile.followers_count) }}</span> Followers
+				</span>
+			</p>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import ReadMore from './../post/ReadMore.vue';
+	import { mapGetters } from 'vuex';
+
+	export default {
+		props: {
+			profile: {
+				type: Object
+			},
+
+			// relationship: {
+			// 	type: Object
+			// }
+		},
+
+		components: {
+			ReadMore
+		},
+
+		data() {
+			return {
+				user: window._sharedData.user,
+				bio: undefined,
+				isLoading: false,
+				relationship: undefined
+			};
+		},
+
+		mounted() {
+			this.rewriteLinks();
+			this.relationship = this.$store.getters.getRelationship(this.profile.id);
+			if(!this.relationship && this.profile.id != this.user.id) {
+				axios.get('/api/pixelfed/v1/accounts/relationships', {
+					params: {
+						'id[]': this.profile.id
+					}
+				})
+				.then(res => {
+					this.relationship = res.data[0];
+					this.$store.commit('updateRelationship', res.data);
+				})
+			}
+		},
+
+		computed: {
+			...mapGetters([
+				'getCustomEmoji'
+			])
+		},
+
+		methods: {
+			getDisplayName() {
+				let self = this;
+				let profile = this.profile;
+				let dn = profile.display_name;
+				if(!dn) {
+					return profile.username;
+				}
+
+				if(dn.includes(':')) {
+					let re = /(<a?)?:\w+:(\d{18}>)?/g;
+					let un = dn.replaceAll(re, function(em) {
+						let shortcode = em.slice(1, em.length - 1);
+						let emoji = self.getCustomEmoji.filter(e => {
+							return e.shortcode == shortcode;
+						});
+						return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`: em;
+					});
+					return un;
+				} else {
+					return dn;
+				}
+			},
+
+			getUsername() {
+				let profile = this.profile;
+				// if(profile.hasOwnProperty('local') && profile.local) {
+				// 	return profile.acct + '@' + window.location.hostname;
+				// }
+				return profile.acct;
+			},
+
+			formatCount(val) {
+				return App.util.format.count(val);
+			},
+
+			goToProfile() {
+				this.$router.push({
+					name: 'profile',
+					path: `/i/web/profile/${this.profile.id}`,
+					params: {
+						id: this.profile.id,
+						cachedProfile: this.profile,
+						cachedUser: this.user
+					}
+				})
+			},
+
+			rewriteLinks() {
+				let content = this.profile.note;
+				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.profile.local == false && !name.includes('@')) {
+						let domain = document.createElement('a');
+						domain.href = this.profile.url;
+						name = name + '@' + domain.hostname;
+					}
+					elr.removeAttribute('target');
+					elr.setAttribute('href', '/i/web/username/' + name);
+				})
+				this.bio = el.outerHTML;
+			},
+
+			performFollow() {
+				this.isLoading = true;
+				this.$emit('follow');
+				setTimeout(() => {
+					this.relationship.following = true;
+					this.isLoading = false;
+				}, 1000);
+			},
+
+			performUnfollow() {
+				this.isLoading = true;
+				this.$emit('unfollow');
+				setTimeout(() => {
+					this.relationship.following = false;
+					this.isLoading = false;
+				}, 1000);
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.profile-hover-card {
+		display: block;
+		width: 300px;
+		overflow: hidden;
+		padding: 0.5rem;
+		border: none;
+		font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+
+		.avatar {
+			border-radius: 15px;
+			box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 15%) !important;
+			margin-bottom: 0.5rem;
+		}
+
+		.display-name {
+			max-width: 240px;
+			word-break: break-word;
+			font-weight: 800;
+			margin-top: 5px;
+			margin-bottom: 2px;
+			line-height: 0.8;
+			font-size: 16px;
+			font-weight: 800 !important;
+			user-select: all;
+			font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+
+			a {
+				color: var(--body-color);
+				text-decoration: none;
+			}
+		}
+
+		.username {
+			max-width: 240px;
+			word-break: break-word;
+			font-size: 12px;
+			margin-top: 0;
+			margin-bottom: 0.6rem;
+			user-select: all;
+			font-weight: 700;
+			overflow: hidden;
+
+			&-link {
+				color: var(--text-lighter);
+				text-decoration: none;
+				margin-right: 4px;
+			}
+
+			&-follows-you {
+				margin: 4px 0;
+
+				span {
+					color: var(--dropdown-item-color);
+					background-color: var(--comment-bg);
+					font-size: 12px;
+					font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+					font-weight: 500;
+					padding: 2px 4px;
+					line-height: 16px;
+					border-radius: 6px;
+				}
+			}
+		}
+
+
+		.pronouns {
+			font-size: 11px;
+			color: #9CA3AF;
+			margin-top: -0.8rem;
+			margin-bottom: 0.6rem;
+			font-weight: 600;
+		}
+
+		.bio {
+			max-width: 240px;
+			max-height: 60px;
+			word-break: break-word;
+			margin-bottom: 0;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			line-height: 1.2;
+			font-size: 12px;
+			color: var(--body-color);
+
+			.invisible {
+				display: none;
+			}
+		}
+
+		.stats {
+			margin-top: 0.5rem;
+			margin-bottom: 0;
+			font-size: 14px;
+			user-select: none;
+			color: var(--body-color);
+
+			.stats-following {
+				margin-right: 0.8rem;
+			}
+
+			.following-count,
+			.followers-count {
+				font-weight: 800;
+			}
+		}
+
+		.btn {
+			&.rounded-pill {
+				min-width: 80px;
+			}
+		}
+	}
+</style>

+ 821 - 0
resources/assets/components/partials/profile/ProfileSidebar.vue

@@ -0,0 +1,821 @@
+<template>
+	<div class="profile-sidebar-component">
+		<div>
+			<div class="d-block d-md-none">
+				<div class="media user-card user-select-none">
+					<div style="position: relative;">
+						<img :src="profile.avatar" class="avatar shadow cursor-pointer" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+					</div>
+					<div class="media-body">
+						<p class="display-name" v-html="getDisplayName()"></p>
+						<p class="username" :class="{ remote: !profile.local }">
+							<a v-if="!profile.local" :href="profile.url" class="primary">&commat;{{ profile.acct }}</a>
+							<span v-else>&commat;{{ profile.acct }}</span>
+							<span v-if="profile.locked">
+								<i class="fal fa-lock ml-1 fa-sm text-lighter"></i>
+							</span>
+						</p>
+						<div class="stats">
+							<div class="stats-posts" @click="toggleTab('index')">
+								<div class="posts-count">{{ formatCount(profile.statuses_count) }}</div>
+								<div class="stats-label">
+									{{ $t('profile.posts') }}
+								</div>
+							</div>
+							<div class="stats-followers" @click="toggleTab('followers')">
+								<div class="followers-count">{{ formatCount(profile.followers_count) }}</div>
+								<div class="stats-label">
+									{{ $t('profile.followers') }}
+								</div>
+							</div>
+							<div class="stats-following" @click="toggleTab('following')">
+								<div class="following-count">{{ formatCount(profile.following_count) }}</div>
+								<div class="stats-label">
+									{{ $t('profile.following') }}
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<div class="d-none d-md-flex justify-content-between align-items-center">
+				<button class="btn btn-link" @click="goBack()">
+					<i class="far fa-chevron-left fa-lg text-lighter"></i>
+				</button>
+				<div>
+					<img :src="getAvatar()" class="avatar img-fluid shadow border" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+					<p v-if="profile.is_admin" class="text-right" style="margin-top: -30px;"><span class="admin-label">Admin</span></p>
+				</div>
+				<!-- <button class="btn btn-link">
+					<i class="far fa-lg fa-cog text-lighter"></i>
+				</button> -->
+
+				<b-dropdown
+					variant="link"
+					right
+					no-caret>
+					<template #button-content>
+						<i class="far fa-lg fa-cog text-lighter"></i>
+					</template>
+
+					<b-dropdown-item v-if="profile.local" href="#" link-class="font-weight-bold" @click.prevent="goToOldProfile()">View in old UI</b-dropdown-item>
+					<b-dropdown-item href="#" link-class="font-weight-bold" @click.prevent="copyTextToClipboard(profile.url)">Copy Link</b-dropdown-item>
+
+
+					<b-dropdown-item v-if="profile.local" :href="'/users/' + profile.username + '.atom'" link-class="font-weight-bold">Atom feed</b-dropdown-item>
+
+					<div v-if="profile.id == user.id">
+						<b-dropdown-divider></b-dropdown-divider>
+						<b-dropdown-item href="/settings/home" link-class="font-weight-bold">
+							<i class="far fa-cog mr-1"></i> Settings
+						</b-dropdown-item>
+					</div>
+
+					<div v-else>
+						<b-dropdown-item v-if="!profile.local" :href="profile.url" link-class="font-weight-bold">View Remote Profile</b-dropdown-item>
+						<b-dropdown-item :href="'/i/web/direct/thread/' + profile.id" link-class="font-weight-bold">Direct Message</b-dropdown-item>
+					</div>
+
+					<div v-if="profile.id !== user.id">
+						<b-dropdown-divider></b-dropdown-divider>
+
+						<b-dropdown-item link-class="font-weight-bold" @click="handleMute()">
+							{{ relationship.muting ? 'Unmute' : 'Mute' }}
+						</b-dropdown-item>
+
+						<b-dropdown-item link-class="font-weight-bold" @click="handleBlock()">
+							{{ relationship.blocking ? 'Unblock' : 'Block' }}
+						</b-dropdown-item>
+
+						<b-dropdown-item :href="'/i/report?type=user&id=' + profile.id" link-class="text-danger font-weight-bold">Report</b-dropdown-item>
+					</div>
+				</b-dropdown>
+			</div>
+
+			<div class="d-none d-md-block text-center">
+				<p v-html="getDisplayName()" class="display-name"></p>
+
+				<p class="username" :class="{ remote: !profile.local }">
+					<a v-if="!profile.local" :href="profile.url" class="primary">&commat;{{ profile.acct }}</a>
+					<span v-else>&commat;{{ profile.acct }}</span>
+					<span v-if="profile.locked">
+						<i class="fal fa-lock ml-1 fa-sm text-lighter"></i>
+					</span>
+				</p>
+
+				<p v-if="user.id != profile.id && (relationship.followed_by || relationship.muting || relationship.blocking)" class="mt-n3 text-center">
+					<span v-if="relationship.followed_by" class="badge badge-primary p-1">Follows you</span>
+					<span v-if="relationship.muting" class="badge badge-dark p-1 ml-1">Muted</span>
+					<span v-if="relationship.blocking" class="badge badge-danger p-1 ml-1">Blocked</span>
+				</p>
+			</div>
+
+			<div class="d-none d-md-block stats py-2">
+				<div class="d-flex justify-content-between">
+					<button
+						class="btn btn-link stat-item"
+						@click="toggleTab('index')">
+						<strong :title="profile.statuses_count">{{ formatCount(profile.statuses_count) }}</strong>
+						<span>{{ $t('profile.posts') }}</span>
+					</button>
+
+					<button
+						class="btn btn-link stat-item"
+						@click="toggleTab('followers')">
+						<strong :title="profile.followers_count">{{ formatCount(profile.followers_count) }}</strong>
+						<span>{{ $t('profile.followers') }}</span>
+					</button>
+
+					<button
+						class="btn btn-link stat-item"
+						@click="toggleTab('following')">
+						<strong :title="profile.following_count">{{ formatCount(profile.following_count) }}</strong>
+						<span>{{ $t('profile.following') }}</span>
+					</button>
+				</div>
+			</div>
+
+			<div class="d-flex align-items-center mb-3 mb-md-0">
+				<div v-if="user.id === profile.id" style="flex-grow: 1;">
+					<!-- <router-link
+						class="btn btn-light font-weight-bold btn-block follow-btn"
+						to="/i/web/settings">
+						{{ $t('profile.editProfile') }}
+					</router-link> -->
+                    <a class="btn btn-light font-weight-bold btn-block follow-btn" href="/settings/home">{{ $t('profile.editProfile') }}</a>
+					<a v-if="!profile.locked" class="btn btn-light font-weight-bold btn-block follow-btn mt-md-n4" href="/i/web/my-portfolio">
+                        My Portfolio
+                        <span class="badge badge-success ml-1">NEW</span>
+                    </a>
+				</div>
+
+				<div v-else-if="profile.locked" style="flex-grow: 1;">
+					<template v-if="!relationship.following && !relationship.requested">
+						<button
+							class="btn btn-primary font-weight-bold btn-block follow-btn"
+							@click="follow"
+							:disabled="relationship.blocking">
+							Request Follow
+						</button>
+						<p v-if="relationship.blocking" class="mt-n4 text-lighter" style="font-size: 11px">You need to unblock this account before you can request to follow.</p>
+					</template>
+
+					<div v-else-if="relationship.requested">
+						<button class="btn btn-primary font-weight-bold btn-block follow-btn" disabled>
+							{{ $t('profile.followRequested') }}
+						</button>
+
+						<p class="small font-weight-bold text-center mt-n4">
+							<a href="#" @click.prevent="cancelFollowRequest()">Cancel Follow Request</a>
+						</p>
+					</div>
+
+					<button
+						v-else-if="relationship.following"
+						class="btn btn-primary font-weight-bold btn-block unfollow-btn"
+						@click="unfollow">
+						{{ $t('profile.unfollow') }}
+					</button>
+				</div>
+
+				<div v-else style="flex-grow: 1;">
+					<template v-if="!relationship.following">
+						<button
+							class="btn btn-primary font-weight-bold btn-block follow-btn"
+							@click="follow"
+							:disabled="relationship.blocking">
+							{{ $t('profile.follow') }}
+						</button>
+						<p v-if="relationship.blocking" class="mt-n4 text-lighter" style="font-size: 11px">You need to unblock this account before you can follow.</p>
+					</template>
+
+					<button
+						v-else
+						class="btn btn-primary font-weight-bold btn-block unfollow-btn"
+						@click="unfollow">
+						{{ $t('profile.unfollow') }}
+					</button>
+				</div>
+
+				<div class="d-block d-md-none ml-3">
+					<b-dropdown
+						variant="link"
+						right
+						no-caret>
+						<template #button-content>
+							<i class="far fa-lg fa-cog text-lighter"></i>
+						</template>
+
+						<b-dropdown-item v-if="profile.local" href="#" link-class="font-weight-bold" @click.prevent="goToOldProfile()">View in old UI</b-dropdown-item>
+						<b-dropdown-item href="#" link-class="font-weight-bold" @click.prevent="copyTextToClipboard(profile.url)">Copy Link</b-dropdown-item>
+
+
+						<b-dropdown-item v-if="profile.local" :href="'/users/' + profile.username + '.atom'" link-class="font-weight-bold">Atom feed</b-dropdown-item>
+
+						<div v-if="profile.id == user.id">
+							<b-dropdown-divider></b-dropdown-divider>
+							<b-dropdown-item href="/settings/home" link-class="font-weight-bold">
+								<i class="far fa-cog mr-1"></i> Settings
+							</b-dropdown-item>
+						</div>
+
+						<div v-else>
+							<b-dropdown-item v-if="!profile.local" :href="profile.url" link-class="font-weight-bold">View Remote Profile</b-dropdown-item>
+
+							<b-dropdown-item :href="'/i/web/direct/thread/' + profile.id" link-class="font-weight-bold">Direct Message</b-dropdown-item>
+						</div>
+
+						<div v-if="profile.id !== user.id">
+							<b-dropdown-divider></b-dropdown-divider>
+
+							<b-dropdown-item link-class="font-weight-bold" @click="handleMute()">
+								{{ relationship.muting ? 'Unmute' : 'Mute' }}
+							</b-dropdown-item>
+
+							<b-dropdown-item link-class="font-weight-bold" @click="handleBlock()">
+								{{ relationship.blocking ? 'Unblock' : 'Block' }}
+							</b-dropdown-item>
+
+							<b-dropdown-item :href="'/i/report?type=user&id=' + profile.id" link-class="text-danger font-weight-bold">Report</b-dropdown-item>
+						</div>
+					</b-dropdown>
+				</div>
+			</div>
+
+			<div v-if="profile.note && renderedBio && renderedBio.length" class="bio-wrapper card shadow-none">
+				<div class="card-body">
+					<div class="bio-body">
+						<div v-html="renderedBio"></div>
+					</div>
+				</div>
+			</div>
+
+			<div class="d-none d-md-block card card-body shadow-none py-2">
+				<p v-if="profile.website" class="small">
+					<span class="text-lighter mr-2">
+						<i class="far fa-link"></i>
+					</span>
+
+					<span>
+						<a :href="profile.website" class="font-weight-bold">{{ profile.website }}</a>
+					</span>
+				</p>
+
+				<p class="mb-0 small">
+					<span class="text-lighter mr-2">
+						<i class="far fa-clock"></i>
+					</span>
+
+					<span v-if="profile.local">
+						{{ $t('profile.joined') }} {{ getJoinedDate() }}
+					</span>
+					<span v-else>
+						{{ $t('profile.joined') }} {{ getJoinedDate() }}
+
+						<span class="float-right primary">
+							<i class="far fa-info-circle" v-b-tooltip.hover title="This user is from a remote server and may have created their account before this date"></i>
+						</span>
+					</span>
+				</p>
+			</div>
+
+			<div class="d-none d-md-flex sidebar-sitelinks">
+				<a href="/site/about">{{ $t('navmenu.about') }}</a>
+				<router-link to="/i/web/help">{{ $t('navmenu.help') }}</router-link>
+				<router-link to="/i/web/language">{{ $t('navmenu.language') }}</router-link>
+				<a href="/site/terms">{{ $t('navmenu.privacy') }}</a>
+				<a href="/site/terms">{{ $t('navmenu.terms') }}</a>
+			</div>
+
+			<div class="d-none d-md-block sidebar-attribution">
+				<a href="https://pixelfed.org" class="font-weight-bold">Powered by Pixelfed</a>
+			</div>
+		</div>
+
+		<b-modal
+			ref="fullBio"
+			centered
+			hide-footer
+			ok-only
+			ok-title="Close"
+			ok-variant="light"
+			:scrollable="true"
+			body-class="p-md-5"
+			title="Bio"
+			>
+			<div v-html="profile.note"></div>
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import { mapGetters } from 'vuex'
+
+	export default {
+		props: {
+			profile: {
+				type: Object
+			},
+
+			relationship: {
+				type: Object,
+				default: (function() {
+					return {
+						following: false,
+						followed_by: false
+					};
+				})
+			},
+
+			user: {
+				type: Object
+			}
+		},
+
+		computed: {
+			...mapGetters([
+				'getCustomEmoji'
+			])
+		},
+
+		data() {
+			return {
+				'renderedBio': ''
+			};
+		},
+
+		mounted() {
+			this.$nextTick(() => {
+				this.setBio();
+			});
+		},
+
+		methods: {
+			getDisplayName() {
+				let self = this;
+				let profile = this.profile;
+				let dn = profile.display_name;
+				if(!dn) {
+					return profile.username;
+				}
+				if(dn.includes(':')) {
+					// let re = /:(::|[^:\n])+:/g;
+					let re = /(<a?)?:\w+:(\d{18}>)?/g;
+					let un = dn.replaceAll(re, function(em) {
+						let shortcode = em.slice(1, em.length - 1);
+						let emoji = self.getCustomEmoji.filter(e => {
+							return e.shortcode == shortcode;
+						});
+						return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`: em;
+					});
+					return un;
+				} else {
+					return dn;
+				}
+			},
+
+			formatCount(val) {
+				return App.util.format.count(val);
+			},
+
+			goBack() {
+				this.$emit('back');
+			},
+
+			showFullBio() {
+				this.$refs.fullBio.show();
+			},
+
+			toggleTab(tab) {
+				event.currentTarget.blur();
+                if(['followers', 'following'].includes(tab)) {
+                    this.$router.push('/i/web/profile/' + this.profile.id + '/' + tab);
+                    return;
+                } else {
+				    this.$emit('toggletab', tab);
+                }
+			},
+
+			getJoinedDate() {
+				let d = new Date(this.profile.created_at);
+				let month = new Intl.DateTimeFormat("en-US", { month: "long" }).format(d);
+				let year = d.getFullYear();
+				return `${month} ${year}`;
+			},
+
+			follow() {
+				event.currentTarget.blur();
+				this.$emit('follow');
+			},
+
+			unfollow() {
+				event.currentTarget.blur();
+				this.$emit('unfollow');
+			},
+
+			setBio() {
+				if(!this.profile.note.length) {
+					return;
+				}
+				if(this.profile.local) {
+					let content = this.profile.hasOwnProperty('note_text') ?
+						this.profile.note_text :
+						this.profile.note.replace(/(<([^>]+)>)/gi, "");
+					this.renderedBio = window.pftxt.autoLink(content, {
+						usernameUrlBase: '/i/web/profile/@',
+						hashtagUrlBase: '/i/web/hashtag/'
+					})
+				} else {
+					if(this.profile.note === '<p></p>') {
+						this.renderedBio = null;
+						return;
+					}
+					let content = this.profile.note;
+					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.profile.local == false && !name.includes('@')) {
+							let domain = document.createElement('a');
+							domain.href = this.profile.url;
+							name = name + '@' + domain.hostname;
+						}
+						elr.removeAttribute('target');
+						elr.setAttribute('href', '/i/web/username/' + name);
+					})
+					this.renderedBio = el.outerHTML;
+				}
+			},
+
+			getAvatar() {
+				if(this.profile.id == this.user.id) {
+					return window._sharedData.user.avatar;
+				}
+
+				return this.profile.avatar;
+			},
+
+			copyTextToClipboard(val) {
+				App.util.clipboard(val);
+			},
+
+			goToOldProfile() {
+				if(this.profile.local) {
+					location.href = this.profile.url + '?fs=1';
+				} else {
+					location.href = '/i/web/profile/_/' + this.profile.id;
+				}
+			},
+
+			handleMute() {
+				let msg = this.relationship.muting ? 'unmuted' : 'muted';
+				let url = this.relationship.muting == true ? '/i/unmute' : '/i/mute';
+				axios.post(url, {
+					type: 'user',
+					item: this.profile.id
+				}).then(res => {
+					this.$emit('updateRelationship', res.data);
+					swal('Success', 'You have successfully '+ msg +' ' + this.profile.acct, 'success');
+				}).catch(err => {
+					if(err.response.status === 422) {
+						swal({
+							title: 'Error',
+							text: err.response?.data?.error,
+							icon: "error",
+							buttons: {
+								review: {
+									text: "Review muted accounts",
+									value: "review",
+									className: "btn-primary"
+								},
+								cancel: true,
+							}
+						})
+						.then((val) => {
+							if(val && val == 'review') {
+								location.href = '/settings/privacy/muted-users';
+								return;
+							}
+						});
+					} else {
+						swal('Error', 'Something went wrong. Please try again later.', 'error');
+					}
+				});
+			},
+
+			handleBlock() {
+				let msg = this.relationship.blocking ? 'unblock' : 'block';
+				let url = this.relationship.blocking == true ? '/i/unblock' : '/i/block';
+				axios.post(url, {
+					type: 'user',
+					item: this.profile.id
+				}).then(res => {
+					this.$emit('updateRelationship', res.data);
+					swal('Success', 'You have successfully '+ msg +'ed ' + this.profile.acct, 'success');
+				}).catch(err => {
+					if(err.response.status === 422) {
+						swal({
+							title: 'Error',
+							text: err.response?.data?.error,
+							icon: "error",
+							buttons: {
+								review: {
+									text: "Review blocked accounts",
+									value: "review",
+									className: "btn-primary"
+								},
+								cancel: true,
+							}
+						})
+						.then((val) => {
+							if(val && val == 'review') {
+								location.href = '/settings/privacy/blocked-users';
+								return;
+							}
+						});
+					} else {
+						swal('Error', 'Something went wrong. Please try again later.', 'error');
+					}
+				});
+			},
+
+			cancelFollowRequest() {
+				if(!window.confirm('Are you sure you want to cancel your follow request?')) {
+					return;
+				}
+				event.currentTarget.blur();
+				this.$emit('unfollow');
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.profile-sidebar-component {
+		margin-bottom: 1rem;
+
+		.avatar {
+			width: 140px;
+			margin-bottom: 1rem;
+			border-radius: 15px;
+		}
+
+		.display-name {
+			font-size: 20px;
+			margin-bottom: 0;
+			word-break: break-word;
+			font-size: 15px;
+			font-weight: 800 !important;
+			user-select: all;
+			line-height: 0.8;
+			font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+		}
+
+		.username {
+			color: var(--primary);
+			font-size: 14px;
+			font-weight: 600;
+			font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+
+			&.remote {
+				font-size: 11px;
+			}
+		}
+
+		.stats {
+			margin-bottom: 1rem;
+
+			.stat-item {
+				max-width: 33%;
+				flex: 0 0 33%;
+				text-align: center;
+				margin: 0;
+				padding: 0;
+				text-decoration: none;
+
+				strong {
+					display: block;
+					color: var(--body-color);
+					font-size: 18px;
+					line-height: 0.9;
+				}
+
+				span {
+					display: block;
+					font-size: 12px;
+					color: #B8C2CC;
+				}
+			}
+		}
+
+		.follow-btn {
+			@media (min-width: 768px) {
+				margin-bottom: 2rem;
+			}
+
+			&.btn-primary {
+				background-color: var(--primary);
+			}
+
+			&.btn-light {
+				border-color: var(--input-border);
+			}
+		}
+
+		.unfollow-btn {
+			@media (min-width: 768px) {
+				margin-bottom: 2rem;
+			}
+
+			background-color: rgba(59, 130, 246, 0.7);
+		}
+
+		.bio-wrapper {
+			margin-bottom: 1rem;
+
+			.bio-body {
+				display: block;
+				position: relative;
+				font-size: 12px !important;
+				white-space: pre-wrap;
+
+				.username {
+					font-size: 12px !important;
+				}
+
+				&.long {
+					max-height: 80px;
+					overflow: hidden;
+
+					&:after {
+						content: '';
+						width: 100%;
+						height: 100%;
+						position: absolute;
+						top: 0;
+						left: 0;
+						background: linear-gradient(180deg, transparent 0, rgba(255, 255, 255, .9) 60%, #fff 90%);
+						z-index: 2;
+					}
+				}
+
+				p {
+					margin-bottom: 0 !important;
+				}
+			}
+
+			.bio-more {
+				position: relative;
+				z-index: 3;
+			}
+		}
+
+		.admin-label {
+			padding: 1px 5px;
+			font-size: 12px;
+			color: #B91C1C;
+			background: #FEE2E2;
+			border: 1px solid #FCA5A5;
+			font-weight: 600;
+			text-transform: capitalize;
+			display: inline-block;
+			border-radius: 8px;
+		}
+
+		.sidebar-sitelinks {
+			margin-top: 1rem;
+			justify-content: space-between;
+			padding: 0;
+
+			a {
+				font-size: 12px;
+				color: #B8C2CC;
+			}
+
+			.active {
+				color: #212529;
+				font-weight: 600;
+			}
+		}
+
+		.sidebar-attribution {
+			margin-top: 0.5rem;
+			font-size: 12px;
+			color: #B8C2CC !important;
+
+			a {
+				color: #B8C2CC !important;
+			}
+		}
+
+		.user-card {
+			align-items: center;
+
+			.avatar {
+				width: 80px;
+				height: 80px;
+				border-radius: 15px;
+				margin-right: 0.8rem;
+				border: 1px solid #E5E7EB;
+
+				@media (min-width: 390px) {
+					width: 100px;
+					height: 100px;
+				}
+			}
+
+			.avatar-update-btn {
+				position: absolute;
+				right: 12px;
+				bottom: 0;
+				width: 20px;
+				height: 20px;
+				background: rgba(255,255,255,0.9);
+				border: 1px solid #dee2e6 !important;
+				padding: 0;
+				border-radius: 50rem;
+
+				&-icon {
+					font-family: 'Font Awesome 5 Free';
+					font-weight: 400;
+					-webkit-font-smoothing: antialiased;
+					display: inline-block;
+					font-style: normal;
+					font-variant: normal;
+					text-rendering: auto;
+					line-height: 1;
+
+					&:before {
+						content: "\F013";
+					}
+				}
+			}
+
+			.username {
+				font-weight: 600;
+				font-size: 13px;
+				margin: 4px 0;
+				word-break: break-word;
+				line-height: 12px;
+				user-select: all;
+
+				@media (min-width: 390px) {
+					margin: 8px 0;
+					font-size: 16px;
+				}
+			}
+
+			.display-name {
+				color: var(--body-color);
+				line-height: 0.8;
+				font-size: 20px;
+				font-weight: 800 !important;
+				word-break: break-word;
+				user-select: all;
+				font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+				margin-bottom: 0;
+
+				@media (min-width: 390px) {
+					font-size: 24px;
+				}
+			}
+
+			.stats {
+				display: flex;
+				justify-content: space-between;
+				flex-direction: row;
+				margin-top: 0;
+				margin-bottom: 0;
+				font-size: 16px;
+				user-select: none;
+
+				.posts-count,
+				.following-count,
+				.followers-count {
+					display: flex;
+					font-weight: 800;
+				}
+
+				.stats-label {
+					color: #94a3b8;
+					font-size: 11px;
+					margin-top: -5px;
+				}
+			}
+		}
+	}
+</style>

+ 2 - 2
resources/assets/components/partials/sidebar.vue

@@ -326,7 +326,7 @@
 
 <script type="text/javascript">
 	import { mapGetters } from 'vuex'
-	import ComposeSimple from './../sections/ComposeSimple.vue';
+	// import ComposeSimple from './../sections/ComposeSimple.vue';
 	import UpdateAvatar from './modal/UpdateAvatar.vue';
 
 	export default {
@@ -409,7 +409,7 @@
 		},
 
 		components: {
-			ComposeSimple,
+			// ComposeSimple,
 			UpdateAvatar
 		},