Add partial components

This commit is contained in:
Daniel Supernault 2023-06-11 15:16:20 -06:00
parent fff692a25c
commit 5361082026
No known key found for this signature in database
GPG key ID: 0DEF1C662C9033F7
21 changed files with 5715 additions and 0 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>