Merge pull request #4465 from pixelfed/staging

Add missing vue components + spa.js
This commit is contained in:
daniel 2023-06-11 16:57:49 -06:00 committed by GitHub
commit 1dd9617da2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 17254 additions and 15 deletions

View file

@ -0,0 +1,128 @@
<template>
<div class="web-wrapper">
<div class="container-fluid mt-3">
<div class="row">
<div class="col-md-3 d-md-block">
<sidebar :user="profile" />
</div>
<div class="col-md-6">
<div class="jumbotron shadow-sm bg-white">
<div class="text-center">
<h1 class="font-weight-bold">What's New</h1>
<p class="lead mb-0">A log of changes in our new UI (Metro 2.0)</p>
</div>
</div>
<div class="mt-4 pb-3">
<p class="lead">Apr 3, 2022</p>
<ul>
<li class="font-weight-bold">
Dark Mode <br />
<p class="small">To enable dark or light mode, click on the top nav menu and then select UI Settings</p>
</li>
<li>Improved internationalization support with the addition of 7 new languages</li>
</ul>
</div>
<div class="mt-4 pb-3">
<p class="lead">March 2022</p>
<ul>
<li class="font-weight-bold">Full screen previews on photo albums</li>
<li class="font-weight-bold">Filter notifications by type on notifications tab</li>
<li>Add "Shared by" link to posts that opens a list of accounts that reblogged the post</li>
<li>Fix private profile feed not loading for owner</li>
</ul>
</div>
<div class="mt-4 pb-3">
<p class="lead">Febuary 2022</p>
<ul>
<li class="font-weight-bold">New Discover layout with My Hashtags, My Memories, Account Insights, Find Friends and Server Timelines</li>
<li class="font-weight-bold">Mobile app drawer menu</li>
<li class="font-weight-bold">Add Preferred Profile Layout UI setting</li>
<li>Add search bar to mobile breakpoints and adjust avatar size when necessary</li>
<li>Improved profile layout on mobile breakpoints</li>
<li class="font-weight-bold">Threaded Comments Beta</li>
<li>Changed default media preview setting to non-fixed height media</li>
<li>Improved Compose Location search, will display popular locations first</li>
<li>Added "Comment" button to comment reply form and changed textarea/multiline icon toggle</li>
<li>Fixed incorrect avatar in profile post comment drawers</li>
<li>Hashtags will now include remote/federated posts on hashtag feeds</li>
<li>Moved media license to post header</li>
<li class="font-weight-bold">Improved Media Previews - disable to restore original preview aspect ratios</li>
<li class="font-weight-bold">Comments + Hovercards - comment usernames now support hovercards</li>
<li class="font-weight-bold">Quick Avatar Updates - click the <i class="far fa-cog"></i> button on your avatar. Supports drag-n-drop and updates without page refresh in most places.</li>
<li class="font-weight-bold">Follows You badge on hovercards when applicable</li>
<li>Add Hide Counts & Stats setting</li>
<li>Fix nsfw videos not displaying sensitive warning</li>
<li>Added mod tools button to posts for admin accounts</li>
</ul>
</div>
<div class="mt-4 pb-3">
<p class="lead">January 2022</p>
<ul>
<li>Fixed comment deletion</li>
<li class="font-weight-bold">New Reaction bar on posts - to disable toggle this option in UI Settings menu</li>
<li class="font-weight-bold">Fresher Comments - fixed bug that prevented new comments from appearing and added a Refresh button to do it manually in the event it's needed</li>
<li>Added Comment Autoloading setting, enabled by default, it loads the first 3 comments</li>
<li>Added Likes feed to your own profile to view posts you liked</li>
<li>Fixed custom emoji rendering on sidebar, profiles and hover cards</li>
<li>Fixed duplicate notifications on feeds</li>
<li>New onboarding home feed with 6 popular accounts to help newcomers find accounts to follow</li>
<li>Added pronouns to hovercards</li>
<li>Fixed custom emoji rendering on home timeline</li>
<li class="font-weight-bold">Added Hovercards to usernames on timelines</li>
<li class="font-weight-bold">Improved search bar, now resolves (and imports) remote accounts and posts, including webfinger addresses</li>
<li>Added full account usernames to notifications on hover</li>
<li>Discover page will default to the Yearly tab if Daily tab is empty</li>
<li>Hashtag feed improvements (fix pagination placeholders, use 4x4 grid on larger screens)</li>
<li class="font-weight-bold">New report modal for posts & comments</li>
<li>Fixed profile <i class="far fa-bars px-1"></i> feed status interactions</li>
<li>Improved profile mobile layout</li>
</ul>
</div>
<div class="pb-3">
<p class="lead">Older</p>
<p>To view the full list of changes, view the project changelog <a href="https://raw.githubusercontent.com/pixelfed/pixelfed/dev/CHANGELOG.md">here</a>.</p>
</div>
</div>
<div class="col-md-3">
<div class="alert alert-primary">
<p class="mb-0 small">We know some of our UI changes are controversial. We value your feedback and are working hard to incorporate it. Your patience is greatly appreciated!</p>
</div>
</div>
</div>
</div>
<drawer />
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import Sidebar from './partials/sidebar.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
},
data() {
return {
isLoaded: false,
profile: undefined,
instance: undefined,
nodeinfo: undefined,
config: window.App.config
}
},
mounted() {
this.profile = window._sharedData.user;
}
}
</script>

View file

@ -0,0 +1,53 @@
<template>
<div class="web-wrapper">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-3 d-md-block">
<sidebar :user="profile" />
</div>
<div class="col-md-8">
<div class="row">
<div class="col-12 col-md-8 offset-md-1">
<compose-modal v-on:close="closeModal" />
</div>
</div>
</div>
</div>
<drawer />
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import Sidebar from './partials/sidebar.vue';
import ComposeModal from './../js/components/ComposeModal.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"compose-modal": ComposeModal
},
data() {
return {
isLoaded: false,
profile: undefined,
}
},
mounted() {
this.profile = window._sharedData.user;
this.isLoaded = true;
},
methods: {
closeModal() {
this.$router.push('/i/web');
}
}
}
</script>

View file

@ -0,0 +1,297 @@
<template>
<div class="dms-page-component">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-3 d-md-block">
<sidebar :user="profile" />
</div>
<div class="col-md-5 offset-md-1 mb-5 order-2 order-md-1">
<h1 class="font-weight-bold mb-4">Direct Messages</h1>
<div v-if="threadsLoaded">
<div v-for="(thread, idx) in threads" class="card shadow-sm mb-1" style="border-radius:15px;">
<div class="card-body p-3">
<div class="media">
<img :src="thread.accounts[0].avatar" width="45" height="45" class="shadow-sm mr-3" style="border-radius: 15px;" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
<div class="media-body">
<!-- <p class="lead mb-n2">{{ thread.accounts[0].display_name }}</p> -->
<div class="d-flex justify-content-between align-items-start mb-1">
<p class="dm-display-name font-weight-bold mb-0">&commat;{{ thread.accounts[0].acct }}</p>
<p class="font-weight-bold small text-muted mb-0">{{ timeago(thread.last_status.created_at) }} ago</p>
</div>
<p class="dm-thread-summary text-muted mr-4" v-html="threadSummary(thread.last_status)"></p>
</div>
<router-link class="btn btn-link stretched-link align-self-center mr-n3" :to="`/i/web/direct/thread/${thread.accounts[0].id}`">
<i class="fal fa-chevron-right fa-lg text-lighter"></i>
</router-link>
</div>
</div>
</div>
<div v-if="!threads || !threads.length" class="row justify-content-center">
<div class="col-12 text-center">
<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
<p class="lead text-muted font-weight-bold">Your inbox is empty</p>
</div>
</div>
<div v-if="canLoadMore">
<intersect @enter="enterIntersect">
<dm-placeholder />
</intersect>
</div>
</div>
<div v-else>
<dm-placeholder />
</div>
</div>
<div class="col-md-3 d-md-block order-1 order-md-2 mb-4">
<button class="btn btn-dark shadow-sm font-weight-bold btn-block" @click="openCompose"><i class="far fa-envelope mr-1"></i> Compose</button>
<hr>
<div class="d-flex d-md-block">
<button
v-for="(tab, index) in tabs"
class="btn shadow-sm font-weight-bold btn-block text-capitalize mt-0 mt-md-2 mx-1 mx-md-0"
:class="[ index === tabIndex ? 'btn-primary' : 'btn-light' ]"
@click="toggleTab(index)"
>
{{ $t('directMessages.' + tab) }}
</button>
</div>
</div>
</div>
<drawer />
</div>
<div v-else class="d-flex justify-content-center align-items-center" style="height:calc(100vh - 58px);">
<b-spinner />
</div>
<b-modal
ref="compose"
hide-header
hide-footer
centered
rounded
size="md"
>
<div class="card shadow-none mt-4">
<div class="card-body d-flex align-items-center justify-content-between flex-column" style="min-height: 50vh;">
<h3 class="font-weight-bold">New Direct Message</h3>
<div>
<p class="mb-0 font-weight-bold">Select Recipient</p>
<autocomplete
:search="composeSearch"
:disabled="composeLoading"
placeholder="@dansup"
aria-label="Search usernames"
:get-result-value="getTagResultValue"
@submit="onTagSubmitLocation"
ref="autocomplete"
>
</autocomplete>
<p class="small text-muted">Search by username, or webfinger (@dansup@pixelfed.social)</p>
<div style="width:300px;"></div>
</div>
<div>
<button class="btn btn-outline-dark rounded-pill font-weight-bold px-5 py-1" @click="closeCompose">Cancel</button>
</div>
</div>
</div>
</b-modal>
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import Sidebar from './partials/sidebar.vue';
import Placeholder from './partials/placeholders/DirectMessagePlaceholder.vue';
import Intersect from 'vue-intersect'
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"intersect": Intersect,
"dm-placeholder": Placeholder
},
data() {
return {
isLoaded: false,
profile: undefined,
canLoadMore: true,
threadsLoaded: false,
composeLoading: false,
threads: [],
tabIndex: 0,
tabs: [
'inbox',
'sent',
'requests'
],
page: 1,
ids: [],
isIntersecting: false
}
},
mounted() {
this.profile = window._sharedData.user;
this.isLoaded = true;
this.fetchThreads();
},
methods: {
fetchThreads() {
axios.get('/api/v1/conversations', {
params: {
scope: this.tabs[this.tabIndex]
}
})
.then(res => {
let data = res.data.filter(m => {
return m && m.hasOwnProperty('last_status') && m.last_status;
})
let ids = data.map(dm => dm.accounts[0].id);
this.ids = ids;
this.threads = data;
this.threadsLoaded = true;
this.page++;
});
},
timeago(ts) {
return App.util.format.timeAgo(ts);
},
enterIntersect() {
if(this.isIntersecting) {
return;
}
this.isIntersecting = true;
axios.get('/api/v1/conversations', {
params: {
scope: this.tabs[this.tabIndex],
page: this.page
}
})
.then(res => {
let data = res.data.filter(m => {
return m && m.hasOwnProperty('last_status') && m.last_status;
})
data.forEach(dm => {
if(this.ids.indexOf(dm.accounts[0].id) == -1) {
this.ids.push(dm.accounts[0].id);
this.threads.push(dm);
}
})
// this.threads.push(...res.data);
if(!res.data.length || res.data.length < 5) {
this.canLoadMore = false;
this.isIntersecting = false;
return;
}
this.page++;
this.isIntersecting = false;
});
},
toggleTab(index) {
event.currentTarget.blur();
this.threadsLoaded = false;
this.page = 1;
this.tabIndex = index;
this.fetchThreads();
},
threadSummary(status, len = 50) {
if(status.pf_type == 'photo') {
let sender = this.profile.id == status.account.id;
let icon = '<div class="' + (sender ? 'text-muted' : 'text-primary') + ' border px-2 py-1 mt-1 rounded" style="font-size:11px;width: fit-content"><i class="far fa-image mr-1"></i> <span>';
icon += sender ? 'Sent a photo' : 'Received a photo';
return icon + '</span></div>';
}
if(status.pf_type == 'video') {
let sender = this.profile.id == status.account.id;
let icon = '<div class="' + (sender ? 'text-muted' : 'text-primary') + ' border px-2 py-1 mt-1 rounded" style="font-size:11px;width: fit-content"><i class="far fa-video mr-1"></i> <span>';
icon += sender ? 'Sent a video' : 'Received a video';
return icon + '</span></div>';
}
let res = '';
if(this.profile.id == status.account.id) {
res += '<i class="far fa-reply-all fa-flip-both"></i> ';
}
let content = status.content;
let text = content.replace(/(<([^>]+)>)/gi, "");
if(text.length > len) {
return res + text.slice(0, len) + '...';
}
return res + text;
},
openCompose() {
this.$refs.compose.show();
},
composeSearch(input) {
if (input.length < 1) { return []; };
let self = this;
let results = [];
return axios.post('/api/direct/lookup', {
q: input
}).then(res => {
return res.data;
});
},
getTagResultValue(result) {
// return '@' + result.name;
return result.local ? '@' + result.name : result.name;
},
onTagSubmitLocation(result) {
//this.$refs.autocomplete.value = '';
this.composeLoading = true;
window.location.href = '/i/web/direct/thread/' + result.id;
return;
},
closeCompose() {
this.$refs.compose.hide();
}
}
}
</script>
<style lang="scss" scoped>
.dms-page-component {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
.dm {
&-thread-summary {
margin-bottom: 0;
font-size: 12px;
line-height: 12px;
}
&-display-name {
font-size: 16px;
}
}
}
</style>

View file

@ -0,0 +1,882 @@
<template>
<div class="dm-page-component">
<div v-if="isLoaded" class="container-fluid mt-lg-3 pb-lg-5">
<div class="row dm-page-component-row">
<div class="col-md-3 d-md-block">
<sidebar :user="profile" />
</div>
<div class="col-md-6 p-0">
<div v-if="loaded && page == 'read'" class="messages-page">
<div class="card shadow-none">
<div class="h4 card-header font-weight-bold text-dark d-flex justify-content-between align-items-center" style="letter-spacing: -0.3px;">
<button class="btn btn-light rounded-pill text-dark" @click="goBack()">
<i class="far fa-chevron-left fa-lg"></i>
</button>
<div>Direct Message</div>
<button class="btn btn-light rounded-pill text-dark" @click="showOptions()">
<i class="far fa-cog fa-lg"></i>
</button>
</div>
<ul class="list-group list-group-flush" style="position:relative;">
<li class="list-group-item border-bottom sticky-top">
<p class="text-center small text-muted mb-0">
Conversation with <span class="font-weight-bold">{{thread.username}}</span>
</p>
</li>
</ul>
<transition name="fade">
<ul v-if="showDMPrivacyWarning && showPrivacyWarning" class="list-group list-group-flush dm-privacy-warning" style="position:absolute;top:105px;width:100%;">
<li class="list-group-item border-bottom sticky-top bg-warning">
<div class="d-flex align-items-center justify-content-between">
<div class="d-none d-lg-block">
<i class="fas fa-exclamation-triangle text-danger fa-lg mr-3"></i>
</div>
<div>
<p class="small warning-text mb-0 font-weight-bold"><span class="d-inline d-lg-none">DMs</span><span class="d-none d-lg-inline">Direct messages on Pixelfed</span> are not end-to-end encrypted.
</p>
<p class="small warning-text mb-0 font-weight-bold">
Use caution when sharing sensitive data.
</p>
</div>
<button class="btn btn-link text-decoration-none" @click="togglePrivacyWarning">
<i class="far fa-times-circle fa-lg"></i>
<span class="d-none d-lg-block">Close</span>
</button>
</div>
</li>
</ul>
</transition>
<ul class="list-group list-group-flush dm-wrapper" style="overflow-y: scroll;position:relative;flex-direction: column-reverse;">
<li v-for="(convo, index) in thread.messages" class="list-group-item border-0 chat-msg mb-n2">
<message
:convo="convo"
:thread="thread"
:hideAvatars="hideAvatars"
:hideTimestamps="hideTimestamps"
:largerText="largerText"
v-on:confirm-delete="deleteMessage(index)" />
</li>
<li v-if="showLoadMore && thread.messages && thread.messages.length > 5" class="list-group-item border-0">
<p class="text-center small text-muted">
<button v-if="!loadingMessages" class="btn btn-primary font-weight-bold rounded-pill btn-sm px-3" @click="loadOlderMessages()">Load Older Messages</button>
<button v-else class="btn btn-primary font-weight-bold rounded-pill btn-sm px-3" disabled>Loading...</button>
</p>
</li>
</ul>
<div class="card-footer bg-white p-0">
<!-- <form class="border-0 rounded-0 align-middle" method="post" action="#">
<textarea class="form-control border-0 rounded-0 no-focus" name="comment" placeholder="Reply ..." autocomplete="off" autocorrect="off" style="height:86px;line-height: 18px;max-height:80px;resize: none; padding-right:115.22px;" v-model="replyText" :disabled="blocked"></textarea>
<input type="button" value="Send" :class="[replyText.length ? 'd-inline-block btn btn-sm btn-primary rounded-pill font-weight-bold reply-btn text-decoration-none text-uppercase' : 'd-inline-block btn btn-sm btn-primary rounded-pill font-weight-bold reply-btn text-decoration-none text-uppercase disabled']" :disabled="replyText.length == 0" @click.prevent="sendMessage"/>
</form> -->
<div class="dm-reply-form">
<div class="dm-reply-form-input-group">
<input
class="form-control form-control-lg"
placeholder="Type a message..."
:disabled="uploading"
v-model="replyText">
<button
class="upload-media-btn btn btn-link"
:disabled="uploading"
@click="uploadMedia">
<i class="far fa-image fa-2x"></i>
</button>
</div>
<button
class="dm-reply-form-submit-btn btn btn-primary"
:disabled="!replyText || !replyText.length || showReplyTooLong"
@click="sendMessage">
<i class="far fa-paper-plane fa-lg"></i>
</button>
</div>
</div>
<div v-if="uploading" class="card-footer dm-status-bar">
<p>Uploading ({{uploadProgress}}%) ...</p>
</div>
<div v-if="showReplyLong" class="card-footer dm-status-bar">
<p class="text-warning">{{ replyText.length }}/500</p>
</div>
<div v-if="showReplyTooLong" class="card-footer dm-status-bar">
<p class="text-danger">{{ replyText.length }}/500 - Your message exceeds the limit of 500 characters</p>
</div>
<div class="d-none card-footer p-0">
<p class="d-flex justify-content-between align-items-center mb-0 px-3 py-1 small">
<!-- <span class="font-weight-bold" style="color: #D69E2E">
<i class="fas fa-circle mr-1"></i>
Typing ...
</span> -->
<span>
<!-- <span class="btn btn-primary btn-sm font-weight-bold py-0 px-3 rounded-pill" @click="uploadMedia">
<i class="fas fa-share mr-1"></i>
Share
</span> -->
<span class="btn btn-primary btn-sm font-weight-bold py-0 px-3 rounded-pill" @click="uploadMedia">
<i class="fas fa-upload mr-1"></i>
Add Photo/Video
</span>
</span>
<input type="file" id="uploadMedia" class="d-none" name="uploadMedia" accept="image/jpeg,image/png,image/gif,video/mp4" >
<span class="text-muted font-weight-bold">{{replyText.length}}/500</span>
</p>
</div>
</div>
</div>
<div v-if="loaded && page == 'options'" class="messages-page">
<div class="card shadow-none">
<div class="h4 card-header font-weight-bold text-dark d-flex justify-content-between align-items-center" style="letter-spacing: -0.3px;">
<button class="btn btn-light rounded-pill text-dark" @click.prevent="goBack('read')">
<i class="far fa-chevron-left fa-lg"></i>
</button>
<div>Direct Message Settings</div>
<div class="btn btn-light rounded-pill text-dark">
<i class="far fa-smile fa-lg"></i>
</div>
</div>
<ul class="list-group list-group-flush" style="height: 698px;">
<div class="list-group-item media border-bottom">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch0" v-model="hideAvatars">
<label class="custom-control-label" for="customSwitch0"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Hide Avatars
</div>
</div>
<div class="list-group-item media border-bottom">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch1" v-model="hideTimestamps">
<label class="custom-control-label" for="customSwitch1"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Hide Timestamps
</div>
</div>
<div class="list-group-item media border-bottom">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch2" v-model="largerText">
<label class="custom-control-label" for="customSwitch2"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Larger Text
</div>
</div>
<!-- <div class="list-group-item media border-bottom">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch3" v-model="autoRefresh">
<label class="custom-control-label" for="customSwitch3"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Auto Refresh
</div>
</div> -->
<div class="list-group-item media border-bottom d-flex align-items-center">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch4" v-model="mutedNotifications">
<label class="custom-control-label" for="customSwitch4"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Mute Notifications
<p class="small mb-0">You will not receive any direct message notifications from <strong>{{thread.username}}</strong>.</p>
</div>
</div>
<div class="list-group-item media border-bottom d-flex align-items-center">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch5" v-model="showDMPrivacyWarning">
<label class="custom-control-label" for="customSwitch5"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Show Privacy Warning
<p class="small mb-0">Show privacy warning indicating that direct messages are not end-to-end encrypted and that caution is advised when sending sensitive/confidential information.</p>
</div>
</div>
</ul>
</div>
</div>
</div>
<div v-if="conversationProfile" class="col-md-3 d-none d-md-block">
<div class="card shadow-sm mb-3" style="border-radius: 15px;">
<div class="small card-header font-weight-bold text-uppercase text-lighter" style="letter-spacing: -0.3px;">
Conversation
</div>
<div class="card-body p-2">
<div class="media user-card user-select-none">
<div>
<img :src="conversationProfile.avatar" class="avatar shadow cursor-pointer" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
</div>
<div class="media-body">
<p
class="display-name"
v-html="conversationProfile.display_name"
@click="gotoProfile(conversationProfile)"
>
</p>
<p
class="username primary"
@click="gotoProfile(conversationProfile)">
&commat;{{ conversationProfile.acct }}
</p>
<p class="stats">
<span class="stats-following">
<span class="following-count">{{ formatCount(conversationProfile.following_count) }}</span> Following
</span>
<span class="stats-followers">
<span class="followers-count">{{ formatCount(conversationProfile.followers_count) }}</span> Followers
</span>
</p>
</div>
</div>
</div>
</div>
<!-- <div class="card shadow-sm mb-3" style="border-radius: 15px;">
<div class="small card-header font-weight-bold text-uppercase text-lighter" style="border-top-left-radius: 15px;letter-spacing: -0.3px;">
History
</div>
</div> -->
<!-- <div class="list-group shadow-sm">
<div class="list-group-item border-0 border-bottom" style="border-width: 1px;">
<p class="mb-0"><i class="far fa-user-plus mr-2"></i> You both follow each other</p>
</div>
<div class="list-group-item border-0">
<p class="mb-0"><i class="far fa-users mr-2"></i> You both follow <a class="font-weight-bold">&commat;pixelfed</a>,<a class="font-weight-bold">&commat;pixeldev</a> and <a class="font-weight-bold">&commat;pixel</a></p>
</div>
</div> -->
</div>
</div>
</div>
<div v-else class="d-flex justify-content-center align-items-center" style="height:calc(100vh - 58px);">
<b-spinner />
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import Sidebar from './partials/sidebar.vue';
import Placeholder from './partials/placeholders/DirectMessagePlaceholder.vue';
import Intersect from 'vue-intersect'
import ProfileCard from './partials/profile/ProfileHoverCard.vue';
import Message from './partials/direct/Message.vue';
export default {
props: ['accountId'],
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"intersect": Intersect,
"dm-placeholder": Placeholder,
"profile-card": ProfileCard,
"message": Message
},
data() {
return {
isLoaded: false,
profile: undefined,
conversationProfile: undefined,
isIntersecting: false,
config: window.App.config,
hideAvatars: true,
hideTimestamps: false,
largerText: false,
autoRefresh: false,
mutedNotifications: false,
blocked: false,
loaded: false,
page: 'read',
pages: ['browse', 'add', 'read'],
threads: [],
thread: false,
threadIndex: false,
replyText: '',
composeUsername: '',
uploading: false,
uploadProgress: null,
min_id: null,
max_id: null,
loadingMessages: false,
showLoadMore: true,
showReplyLong: false,
showReplyTooLong: false,
showPrivacyWarning: true,
}
},
mounted() {
this.profile = window._sharedData.user;
this.isLoaded = true;
let self = this;
axios.get('/api/v1/accounts/' + this.accountId)
.then(res => {
this.conversationProfile = res.data;
});
axios.get('/api/direct/thread', {
params: {
pid: self.accountId
}
})
.then(res => {
self.loaded = true;
let d = res.data;
this.thread = d;
this.threads = [d];
this.threadIndex = 0;
let mids = d.messages.map(m => m.id);
this.max_id = Math.max(...mids);
this.min_id = Math.min(...mids);
this.mutedNotifications = d.muted;
this.markAsRead();
//this.messagePoll();
// setTimeout(function() {
// let objDiv = document.querySelector('.dm-wrapper');
// objDiv.scrollTop = objDiv.scrollHeight;
// }, 300);
});
let options = localStorage.getItem('px_dm_options');
if(options) {
options = JSON.parse(options);
this.hideAvatars = options.hideAvatars;
this.hideTimestamps = options.hideTimestamps;
this.largerText = options.largerText;
}
},
computed: {
showDMPrivacyWarning: {
get() {
return this.$store.state.showDMPrivacyWarning;
},
set(val) {
window.localStorage.removeItem('pf_m2s.dmwarncounter');
this.$store.commit('setShowDMPrivacyWarning', val);
}
},
},
watch: {
mutedNotifications: function(v) {
if(v) {
axios.post('/api/direct/mute', {
id: this.accountId
}).then(res => {
});
} else {
axios.post('/api/direct/unmute', {
id: this.accountId
}).then(res => {
});
}
this.mutedNotifications = v;
},
hideAvatars: function(v) {
this.hideAvatars = v;
this.updateOptions();
},
hideTimestamps: function(v) {
this.hideTimestamps = v;
this.updateOptions();
},
largerText: function(v) {
this.largerText = v;
this.updateOptions();
},
replyText: function(v) {
let limit = 500;
if(v.length < limit) {
this.showReplyLong = false;
this.showReplyTooLong = false;
}
if(v.length > limit) {
this.showReplyLong = false;
this.showReplyTooLong = true;
return;
}
if(v.length > (limit - 50)) {
this.showReplyTooLong = false;
this.showReplyLong = true;
return;
}
}
},
methods: {
sendMessage() {
let self = this;
let rt = this.replyText;
axios.post('/api/direct/create', {
'to_id': this.threads[this.threadIndex].id,
'message': rt,
'type': self.isEmoji(rt) && rt.length < 10 ? 'emoji' : 'text'
}).then(res => {
let msg = res.data;
self.threads[self.threadIndex].messages.unshift(msg);
let mids = self.threads[self.threadIndex].messages.map(m => m.id);
this.max_id = Math.max(...mids)
this.min_id = Math.min(...mids)
// setTimeout(function() {
// var objDiv = document.querySelector('.dm-wrapper');
// objDiv.scrollTop = objDiv.scrollHeight;
// }, 300);
}).catch(err => {
if(err.response.status == 403) {
self.blocked = true;
swal('Profile Unavailable', 'You cannot message this profile at this time.', 'error');
}
})
this.replyText = '';
},
truncate(t) {
return _.truncate(t);
},
deleteMessage(index) {
let c = window.confirm('Are you sure you want to delete this message?');
if(c) {
axios.delete('/api/direct/message', {
params: {
id: this.thread.messages[index].reportId
}
}).then(res => {
this.thread.messages.splice(index ,1);
});
}
},
reportMessage() {
this.closeCtxMenu();
let url = '/i/report?type=post&id=' + this.ctxContext.reportId;
window.location.href = url;
return;
},
uploadMedia(event) {
let self = this;
$(document).on('change', '#uploadMedia', function(e) {
self.handleUpload();
});
let el = $(event.target);
el.attr('disabled', '');
$('#uploadMedia').click();
el.blur();
el.removeAttr('disabled');
},
handleUpload() {
let self = this;
if(self.uploading) {
return;
}
self.uploading = true;
let io = document.querySelector('#uploadMedia');
if(!io.files.length) {
this.uploading = false;
}
Array.prototype.forEach.call(io.files, function(io, i) {
let type = io.type;
let acceptedMimes = self.config.uploader.media_types.split(',');
let validated = $.inArray(type, acceptedMimes);
if(validated == -1) {
swal('Invalid File Type', 'The file you are trying to add is not a valid mime type. Please upload a '+self.config.uploader.media_types+' only.', 'error');
self.uploading = false;
return;
}
let form = new FormData();
form.append('file', io);
form.append('to_id', self.threads[self.threadIndex].id);
let xhrConfig = {
onUploadProgress: function(e) {
let progress = Math.round( (e.loaded * 100) / e.total );
self.uploadProgress = progress;
}
};
axios.post('/api/direct/media', form, xhrConfig)
.then(function(e) {
self.uploadProgress = 100;
self.uploading = false;
let msg = {
id: e.data.id,
type: e.data.type,
reportId: e.data.reportId,
isAuthor: true,
text: null,
media: e.data.url,
timeAgo: '1s',
seen: null
};
self.threads[self.threadIndex].messages.unshift(msg);
// setTimeout(function() {
// var objDiv = document.querySelector('.dm-wrapper');
// objDiv.scrollTop = objDiv.scrollHeight;
// }, 300);
}).catch(function(e) {
if(e.hasOwnProperty('response') && e.response.hasOwnProperty('status') ) {
switch(e.response.status) {
case 451:
self.uploading = false;
io.value = null;
swal('Banned Content', 'This content has been banned and cannot be uploaded.', 'error');
break;
default:
self.uploading = false;
io.value = null;
swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error');
break;
}
}
});
io.value = null;
self.uploadProgress = 0;
});
},
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;
},
markAsRead() {
return;
axios.post('/api/direct/read', {
pid: this.accountId,
sid: this.max_id
}).then(res => {
}).catch(err => {
});
},
loadOlderMessages() {
let self = this;
this.loadingMessages = true;
axios.get('/api/direct/thread', {
params: {
pid: this.accountId,
max_id: this.min_id,
}
}).then(res => {
let d = res.data;
if(!d.messages.length) {
this.showLoadMore = false;
this.loadingMessages = false;
return;
}
let cids = this.thread.messages.map(m => m.id);
let m = d.messages.filter(m => {
return cids.indexOf(m.id) == -1;
}).reverse();
let mids = m.map(m => m.id);
let min_id = Math.min(...mids);
if(min_id == this.min_id) {
this.showLoadMore = false;
this.loadingMessages = false;
return;
}
this.min_id = min_id;
this.thread.messages.push(...m);
setTimeout(function() {
self.loadingMessages = false;
}, 500);
}).catch(err => {
this.loadingMessages = false;
})
},
messagePoll() {
let self = this;
setInterval(function() {
axios.get('/api/direct/thread', {
params: {
pid: self.accountId,
min_id: self.thread.messages[self.thread.messages.length - 1].id
}
}).then(res => {
});
}, 5000);
},
showOptions() {
this.page = 'options';
},
updateOptions() {
let options = {
v: 1,
hideAvatars: this.hideAvatars,
hideTimestamps: this.hideTimestamps,
largerText: this.largerText
}
window.localStorage.setItem('px_dm_options', JSON.stringify(options));
},
formatCount(val) {
return window.App.util.format.count(val);
},
goBack(page = false) {
if(page) {
this.page = page;
} else {
this.$router.push('/i/web/direct');
}
},
gotoProfile(profile) {
this.$router.push(`/i/web/profile/${profile.id}`);
},
togglePrivacyWarning() {
console.log('clicked toggle privacy warning');
let ls = window.localStorage;
let key = 'pf_m2s.dmwarncounter';
this.showPrivacyWarning = false;
if(ls.getItem(key)) {
let count = ls.getItem(key);
count++;
ls.setItem(key, count);
if(count > 5) {
this.showDMPrivacyWarning = false;
}
} else {
ls.setItem(key, 1);
}
}
}
}
</script>
<style lang="scss" scoped>
.dm-page-component {
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
.user-card {
align-items: center;
.avatar {
width: 60px;
height: 60px;
border-radius: 15px;
margin-right: 0.8rem;
border: 1px solid var(--border-color);
}
.avatar-update-btn {
position: absolute;
right: 12px;
bottom: 0;
width: 20px;
height: 20px;
background: rgba(255,255,255,0.9);
border: 1px solid #dee2e6 !important;
padding: 0;
border-radius: 50rem;
&-icon {
font-family: 'Font Awesome 5 Free';
font-weight: 400;
-webkit-font-smoothing: antialiased;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
&:before {
content: "\F013";
}
}
}
.username {
font-weight: 600;
font-size: 13px;
margin-bottom: 0;
cursor: pointer;
}
.display-name {
color: var(--body-color);
line-height: 0.8;
font-size: 14px;
font-weight: 800 !important;
user-select: all;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
margin-bottom: 0;
cursor: pointer;
}
.stats {
margin-top: 0;
margin-bottom: 0;
font-size: 12px;
user-select: none;
.stats-following {
margin-right: 0.8rem;
}
.following-count,
.followers-count {
font-weight: 800;
}
}
}
.dm-reply-form {
display: flex;
justify-content: space-between;
background-color: var(--card-bg);
padding: 1rem;
.btn:focus,
.btn.focus,
input:focus,
input.focus {
outline: 0;
box-shadow: none;
}
:disabled {
opacity: 20% !important;
}
&-input-group {
width: 100%;
margin-right: 10px;
position: relative;
input {
position: absolute;
padding-right: 60px;
background-color: var(--comment-bg);
border-radius: 25px;
border-color: var(--comment-bg) !important;
font-size: 15px;
color: var(--dark);
}
.upload-media-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-lighter);
}
}
&-submit-btn {
width: 48px;
height: 48px;
border-radius: 24px;
}
}
.dm-status-bar {
font-size: 12px;
font-weight: 600;
color: var(--text-lighter);
p {
margin-bottom: 0;
}
}
.dm-privacy-warning {
p,
.btn {
color: #000;
}
.warning-text {
text-align: left;
@media (min-width: 992px) {
text-align: center;
}
}
}
&-row {
.dm-wrapper {
padding-top: 100px;
height: calc(100vh - 240px);
@media (min-width: 500px) {
min-height: 40vh;
}
@media (min-width: 700px) {
height: 60vh;
}
}
}
}
</style>

View file

@ -0,0 +1,405 @@
<template>
<div class="web-wrapper">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3">
<sidebar :user="profile" />
</div>
<div v-if="tab == 'index'" class="col-md-8 col-lg-9 mt-n4">
<div v-if="profile.is_admin" class="d-md-flex my-md-3">
<grid-card
:dark="true"
:title="'Hello ' + profile.username"
subtitle="Welcome to the new Discover experience! Only admins can see this"
button-text="Manage Discover Settings"
button-link="/i/web/discover/settings"
icon-class="fal fa-cog"
:small="true" />
</div>
<!-- <section class="mb-1 mb-md-3 mb-lg-4">
<news-slider />
</section> -->
<!-- <discover-spotlight /> -->
<!-- <div class="d-md-flex my-md-3">
<grid-card
:dark="true"
title="The Not So Trending"
subtitle="Explore the posts that deserve more attention"
button-text="Explore posts"
icon-class="fal fa-analytics"
button-link="/i/web/discover/future-trending"
:button-event="true"
v-on:btn-click="toggleTab('trending')"
:small="true" />
<grid-card
title="Behind The Posts"
subtitle="Discover the people"
button-text="Discover People"
button-link="/i/web/discover/people"
icon-class="fal fa-user-friends"
:small="true" />
</div> -->
<daily-trending v-on:btn-click="toggleTab('trending')"/>
<!-- <div class="d-md-flex my-md-3">
<grid-card
title="Explore Loops"
subtitle="Loops are short, looping videos"
button-text="Explore Loops"
icon-class="fal fa-camcorder"
button-link="/i/web/discover/loops"
:small="false" />
<grid-card
:dark="true"
title="Popular Places"
subtitle="Explore posts by popular locations"
button-text="Explore Popular Places"
icon-class="fal fa-map"
:button-event="true"
v-on:btn-click="toggleTab('popular-places')"
button-link="/i/web/discover/popular-places"
:small="false" />
</div> -->
<div class="d-md-flex my-md-3">
<grid-card
v-if="config.hashtags.enabled"
:dark="true"
title="My Hashtags"
subtitle="Explore posts tagged with hashtags you follow"
button-text="Explore Posts"
button-link="/i/web/discover/my-hashtags"
icon-class="fal fa-hashtag"
:small="false" />
<grid-card
v-if="config.memories.enabled"
title="My Memories"
subtitle="A distant look back"
button-text="View Memories"
button-link="/i/web/discover/my-memories"
icon-class="fal fa-history"
:small="false" />
</div>
<div class="d-md-flex my-md-3">
<grid-card
v-if="config.insights.enabled"
title="Account Insights"
subtitle="Get a rich overview of your account activity and interactions"
button-text="View Account Insights"
icon-class="fal fa-user-circle"
button-link="/i/web/discover/account-insights"
:small="false" />
<grid-card
v-if="config.friends.enabled"
:dark="true"
title="Find Friends"
subtitle="Find accounts to follow based on common interests"
button-text="Find Friends & Followers"
button-link="/i/web/discover/find-friends"
icon-class="fal fa-user-plus"
:small="false" />
</div>
<div class="d-md-flex my-md-3">
<grid-card
v-if="config.server.enabled && config.server.domains && config.server.domains.length"
:dark="true"
title="Server Timelines"
subtitle="Browse timelines of a specific remote instance"
button-text="Browse Server Feeds"
icon-class="fal fa-list"
button-link="/i/web/discover/server-timelines"
:small="false" />
<!-- <grid-card
title="Curate the Spotlight"
subtitle="Apply to curate the spotlight for one week"
button-text="Apply to Curate Spotlight"
button-link="/i/web/discover/spotlight/curate/apply"
icon-class="fal fa-thumbs-up"
:small="false" /> -->
</div>
</div>
<div v-else-if="tab == 'trending'" class="col-md-8 col-lg-9 mt-n4">
<discover :profile="profile" />
</div>
<div v-else-if="tab == 'popular-places'" class="col-md-8 col-lg-9 mt-n4">
<section class="mt-3 mb-5 section-explore">
<div class="profile-timeline">
<div class="row p-0 mt-5">
<div class="col-12 mb-4 d-flex justify-content-between align-items-center">
<p class="d-block d-md-none h1 font-weight-bold mb-0 font-default">Popular Places</p>
<p class="d-none d-md-block display-4 font-weight-bold mb-0 font-default">Popular Places</p>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-12 col-md-12 mb-3">
<div class="card-img big">
<img src="/img/places/nyc.jpg">
<div class="title font-default">New York City</div>
</div>
</div>
<div class="col-12 col-md-6 mb-3">
<div class="card-img">
<img src="/img/places/edmonton.jpg">
<div class="title font-default">Edmonton</div>
</div>
</div>
<div class="col-12 col-md-6 mb-3">
<div class="card-img">
<img src="/img/places/paris.jpg">
<div class="title font-default">Paris</div>
</div>
</div>
<div class="col-12 col-md-4 mb-3">
<div class="card-img">
<img src="/img/places/london.jpg">
<div class="title font-default">London</div>
</div>
</div>
<div class="col-12 col-md-4 mb-3">
<div class="card-img">
<img src="/img/places/vancouver.jpg">
<div class="title font-default">Vancouver</div>
</div>
</div>
<div class="col-12 col-md-4 mb-3">
<div class="card-img">
<img src="/img/places/toronto.jpg">
<div class="title font-default">Toronto</div>
</div>
</div>
</div>
</section>
</div>
</div>
<drawer />
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import Sidebar from './partials/sidebar.vue';
import Rightbar from './partials/rightbar.vue';
import Discover from './sections/DiscoverFeed.vue';
import DiscoverNewsSlider from './partials/discover/news-slider.vue';
import DiscoverSpotlight from './partials/discover/discover-spotlight.vue';
import DailyTrending from './partials/discover/daily-trending.vue';
import DiscoverGridCard from './partials/discover/grid-card.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"rightbar": Rightbar,
"discover": Discover,
"news-slider": DiscoverNewsSlider,
"discover-spotlight": DiscoverSpotlight,
"daily-trending": DailyTrending,
"grid-card": DiscoverGridCard
},
data() {
return {
isLoaded: false,
profile: undefined,
config: {},
tab: 'index',
popularAccounts: [],
followingIndex: undefined
}
},
updated() {
// let u = new URLSearchParams(window.location.search);
// if(u.has('ft') && u.get('ft') == '1') {
// this.tab = 'index';
// }
},
mounted() {
this.profile = window._sharedData.user;
this.fetchConfig();
},
methods: {
fetchConfig() {
axios.get('/api/pixelfed/v2/discover/meta')
.then(res => {
this.config = res.data;
this.isLoaded = true;
window._sharedData.discoverMeta = res.data;
// this.fetchPopularAccounts();
})
},
fetchPopularAccounts() {
// axios.get('/api/pixelfed/discover/accounts/popular')
// .then(res => {
// this.popularAccounts = res.data;
// })
},
followProfile(index) {
event.currentTarget.blur();
this.followingIndex = index;
let id = this.popularAccounts[index].id;
axios.post('/api/v1/accounts/' + id + '/follow')
.then(res => {
this.followingIndex = undefined;
this.popularAccounts.splice(index, 1);
}).catch(err => {
this.followingIndex = undefined;
swal('Oops!', 'An error occured when attempting to follow this account.', 'error');
});
},
goToProfile(account) {
this.$router.push({
path: `/i/web/profile/${account.id}`,
params: {
id: account.id,
cachedProfile: account,
cachedUser: this.profile
}
})
},
toggleTab(index) {
this.tab = index;
setTimeout(() => {
window.scrollTo({top: 0, behavior: 'smooth'});
}, 300);
},
openManageModal() {
event.currentTarget.blur();
swal('Settings', 'Discover settings here', 'info');
}
}
}
</script>
<style lang="scss" scoped>
.card-img {
position: relative;
img {
object-fit: cover;
width: 100%;
height: 200px;
border-radius: 10px;
}
&:before,
&:after {
content: "";
background: rgba(0,0,0,0.2);
z-index: 2;
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
border-radius: 10px;
}
.title {
position: absolute;
bottom: 5px;
left: 10px;
font-size: 40px;
color: #fff;
z-index: 3;
font-weight: 700;
}
&.big {
img {
height: 300px;
}
}
}
.font-default {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
letter-spacing: -0.7px;
}
.bg-stellar {
background: #7474BF;
background: -webkit-linear-gradient(to right, #348AC7, #7474BF);
background: linear-gradient(to right, #348AC7, #7474BF);
}
.bg-berry {
background: #5433FF;
background: -webkit-linear-gradient(to right, #acb6e5, #86fde8);
background: linear-gradient(to right, #acb6e5, #86fde8);
}
.bg-midnight {
background: #232526;
background: -webkit-linear-gradient(to right, #414345, #232526);
background: linear-gradient(to right, #414345, #232526);
}
.media-body {
margin-right: 0.5rem;
}
.avatar {
border-radius: 15px;
}
.username {
font-size: 14px;
line-height: 14px;
margin-bottom: 2px;
word-break: break-word !important;
word-wrap: break-word !important;
}
.display-name {
margin-bottom: 0;
font-size: 12px;
word-break: break-word !important;
word-wrap: break-word !important;
}
.follower-count {
margin-bottom: 0;
font-size: 10px;
word-break: break-word !important;
word-wrap: break-word !important;
}
.follow {
background-color: var(--primary);
border-radius: 18px;
font-weight: 600;
padding: 5px 15px;
}
</style>

View file

@ -0,0 +1,332 @@
<template>
<div class="hashtag-component">
<div class="container-fluid mt-3">
<div class="row">
<div class="col-md-3 d-md-block">
<sidebar :user="profile" />
</div>
<div class="col-md-9">
<div class="card border-0 shadow-sm mb-3" style="border-radius: 18px;">
<div class="card-body">
<div class="media align-items-center py-3">
<div class="media-body">
<p class="h3 text-break mb-0">
<span class="text-lighter">#</span>{{ hashtag.name }}
</p>
<p v-if="hashtag.count && hashtag.count > 100" class="mb-0 text-muted font-weight-bold">
{{ formatCount(hashtag.count) }} Posts
</p>
</div>
<template v-if="hashtag && hashtag.hasOwnProperty('following') && feed && feed.length">
<button
v-if="hashtag.following"
:disabled="followingLoading"
class="btn btn-light hashtag-follow border rounded-pill font-weight-bold py-1 px-4"
@click="unfollowHashtag()"
>
<b-spinner v-if="followingLoading" small />
<span v-else>
{{ $t('profile.unfollow') }}
</span>
</button>
<button
v-else
:disabled="followingLoading"
class="btn btn-primary hashtag-follow font-weight-bold rounded-pill py-1 px-4"
@click="followHashtag()"
>
<b-spinner v-if="followingLoading" small />
<span v-else>
{{ $t('profile.follow') }}
</span>
</button>
</template>
</div>
</div>
</div>
<template v-if="isLoaded && feedLoaded">
<div class="row mx-0 hashtag-feed">
<div class="col-6 col-md-4 col-lg-3 p-1" v-for="(status, index) in feed" :key="'tlob:'+index">
<a
class="card info-overlay card-md-border-0"
:href="statusUrl(status)"
@click.prevent="goToPost(status)">
<div class="square">
<div v-if="status.sensitive" class="square-content">
<div class="info-overlay-text-label">
<h5 class="text-white m-auto font-weight-bold">
<span>
<span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
</span>
</h5>
</div>
<blur-hash-canvas
width="32"
height="32"
:hash="status.media_attachments[0].blurhash"
/>
</div>
<div v-else class="square-content">
<blur-hash-image
width="32"
height="32"
:hash="status.media_attachments[0].blurhash"
:src="status.media_attachments[0].url"
/>
</div>
<span v-if="status.pf_type == 'photo:album'" class="float-right mr-3 post-icon"><i class="fas fa-images fa-2x"></i></span>
<span v-if="status.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
<span v-if="status.pf_type == 'video:album'" class="float-right mr-3 post-icon"><i class="fas fa-film fa-2x"></i></span>
<div class="info-overlay-text">
<h5 class="text-white m-auto font-weight-bold">
<span>
<span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
<span class="d-flex-inline">{{formatCount(status.reply_count)}}</span>
</span>
</h5>
</div>
</div>
</a>
</div>
<div v-if="canLoadMore" class="col-12">
<intersect @enter="enterIntersect">
<div class="d-flex justify-content-center py-5">
<b-spinner />
</div>
</intersect>
<!-- <div v-else class="ph-item">
<div class="ph-picture big"></div>
</div> -->
</div>
</div>
<div v-if="feedLoaded && !feed.length" class="row mx-0 hashtag-feed justify-content-center">
<div class="col-12 col-md-8 text-center">
<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;max-width:400px">
<p class="lead text-muted font-weight-bold">{{ $t('hashtags.emptyFeed') }}</p>
</div>
</div>
</template>
<template v-else>
<div class="row justify-content-center align-items-center pt-5 mt-5">
<b-spinner />
</div>
</template>
</div>
</div>
<drawer />
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import Intersect from 'vue-intersect'
import Sidebar from './partials/sidebar.vue';
import Rightbar from './partials/rightbar.vue';
export default {
props: {
id: {
type: String
}
},
components: {
"drawer": Drawer,
"intersect": Intersect,
"sidebar": Sidebar,
"rightbar": Rightbar,
},
data() {
return {
isLoaded: false,
profile: undefined,
canLoadMore: false,
isIntersecting: false,
feedLoaded: false,
feed: [],
page: 1,
hashtag: {
name: this.id,
count: 0
},
followingLoading: false,
maxId: undefined,
};
},
mounted() {
this.init();
},
watch: {
'$route': 'init'
},
methods: {
init() {
this.profile = window._sharedData.user;
axios.get('/api/v1/tags/' + this.id, {
params: {
'_pe': 1
}
})
.then(res => {
this.hashtag = res.data;
})
.catch(err => {
swal('Error', 'Something went wrong, please try again later!', 'error');
this.isLoaded = true;
this.feedLoaded = true;
})
.finally(() => {
this.fetchFeed();
})
},
fetchFeed() {
axios.get('/api/v1/timelines/tag/' + this.id, {
params: {
limit: 80,
}
})
.then(res => {
if(res.data && res.data.length) {
this.feed = res.data;
this.maxId = res.data[res.data.length - 1].id;
return true;
} else {
this.feedLoaded = true;
this.isLoaded = true;
return false;
}
})
.then(res => {
this.canLoadMore = res;
})
.finally(() => {
this.feedLoaded = true;
this.isLoaded = true;
})
},
statusUrl(status) {
return '/i/web/post/' + status.id;
},
formatCount(val) {
return App.util.format.count(val);
},
enterIntersect() {
if(this.isIntersecting) {
return;
}
this.isIntersecting = true;
axios.get('/api/v1/timelines/tag/' + this.id, {
params: {
max_id: this.maxId,
limit: 40,
}
})
.then(res => {
if(res.data && res.data.length) {
this.feed.push(...res.data);
this.maxId = res.data[res.data.length - 1].id;
return true;
} else {
return false;
}
})
.then(res => {
this.canLoadMore = res;
})
.finally(() => {
this.isIntersecting = false;
})
},
goToPost(status) {
this.$router.push({
name: 'post',
path: `/i/web/post/${status.id}`,
params: {
id: status.id,
cachedStatus: status,
cachedProfile: this.profile
}
})
},
followHashtag() {
this.followingLoading = true;
axios.post('/api/v1/tags/' + this.id + '/follow')
.then(res => {
setTimeout(() => {
this.hashtag.following = true;
this.followingLoading = false;
}, 500);
});
},
unfollowHashtag() {
this.followingLoading = true;
axios.post('/api/v1/tags/' + this.id + '/unfollow')
.then(res => {
setTimeout(() => {
this.hashtag.following = false;
this.followingLoading = false;
}, 500);
});
},
}
}
</script>
<style lang="scss">
.hashtag-component {
.hashtag-feed {
.card,
.info-overlay-text,
.info-overlay-text-label,
img,
canvas {
border-radius: 18px !important;
}
}
.hashtag-follow {
width: 200px;
}
.ph-wrapper {
padding: 0.25rem;
.ph-item {
margin: 0;
padding: 0;
border: none;
background-color: transparent;
.ph-picture {
height: auto;
padding-bottom: 100%;
border-radius: 18px;
}
& > * {
margin-bottom: 0;
}
}
}
}
</style>

View file

@ -0,0 +1,113 @@
<template>
<div class="web-wrapper">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3">
<sidebar
:user="profile"
@refresh="shouldRefresh = true" />
</div>
<div class="col-md-8 col-lg-6 px-0">
<story-carousel
v-if="storiesEnabled"
:profile="profile" />
<timeline
:profile="profile"
:scope="scope"
:key="scope"
v-on:update-profile="updateProfile"
:refresh="shouldRefresh"
@refreshed="shouldRefresh = false" />
</div>
<div class="d-none d-lg-block col-lg-3">
<rightbar class="sticky-top sidebar" />
</div>
</div>
<drawer />
</div>
<div v-else class="d-flex justify-content-center align-items-center" style="height:calc(100vh - 58px);">
<b-spinner />
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import Sidebar from './partials/sidebar.vue';
import Rightbar from './partials/rightbar.vue';
import Timeline from './sections/Timeline.vue';
import Notifications from './sections/Notifications.vue';
import StoryCarousel from './partials/timeline/StoryCarousel.vue';
export default {
props: {
scope: {
type: String,
default: 'home'
}
},
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"timeline": Timeline,
"rightbar": Rightbar,
"story-carousel": StoryCarousel,
},
data() {
return {
isLoaded: false,
profile: undefined,
recommended: [],
trending: [],
storiesEnabled: false,
shouldRefresh: false
}
},
mounted() {
this.init();
},
watch: {
'$route': 'init'
},
methods: {
init() {
this.profile = window._sharedData.user;
this.isLoaded = true;
this.storiesEnabled = window.App?.config?.features?.hasOwnProperty('stories') ? window.App.config.features.stories : false;
},
updateProfile(delta) {
this.profile = Object.assign(this.profile, delta);
}
}
}
</script>
<style lang="scss" scoped>
.avatar {
border-radius: 15px;
}
.username {
margin-bottom: -6px;
}
.btn-white {
background-color: #fff;
border: 1px solid #F3F4F6;
}
.sidebar {
top: 90px;
}
</style>

View file

@ -0,0 +1,111 @@
<template>
<div class="web-wrapper">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-3 d-md-block">
<sidebar :user="profile" />
</div>
<div class="col-md-6">
<div class="jumbotron shadow-sm bg-white">
<div class="text-center">
<h1 class="font-weight-bold mb-0">Language</h1>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="locale-changer form-group">
<label>Language</label>
<select class="form-control" v-model="locale">
<option v-for="(lang, i) in langs" :key="`Lang${i}`" :value="lang">
{{ fullName(lang) }}
<template v-if="fullName(lang) != localeName(lang)"> · {{ localeName(lang) }}</template>
</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<drawer />
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import Sidebar from './partials/sidebar.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
},
data() {
return {
isLoaded: false,
profile: undefined,
locale: 'en',
// langs: ["af","ar","ca","cs","cy","da","de","el","en","eo","es","eu","fa","fi","fr","gl","he","hu","id","it","ja","ko","ms","nl","no","oc","pl","pt","ro","ru","sr","sv","th","tr","uk","vi","zh","zh-cn","zh-tw"]
langs: [
"en",
"ar",
"ca",
"de",
"el",
"es",
"eu",
"fr",
"he",
"gd",
"gl",
"id",
"it",
"ja",
"nl",
"pl",
"pt",
"ru",
"uk",
"vi"
]
}
},
mounted() {
this.profile = window._sharedData.user;
this.isLoaded = true;
this.locale = this.$i18n.locale;
},
watch: {
locale: function(val) {
this.loadLang(val);
}
},
methods: {
fullName(val) {
const factory = new Intl.DisplayNames([val], { type: 'language' });
return factory.of(val);
},
localeName(val) {
const factory = new Intl.DisplayNames([this.$i18n.locale], { type: 'language' });
return factory.of(val);
},
loadLang(lang) {
axios.post('/api/pixelfed/web/change-language.json', {
v: 0.1,
l: lang
})
.then(res => {
this.$i18n.locale = lang;
})
}
}
}
</script>

View file

@ -0,0 +1,29 @@
<template>
<div class="container d-flex justify-content-center">
<div class="error-page py-5 my-5" style="max-width: 450px;">
<h3 class="font-weight-bold">404 &nbsp; Page Not Found</h3>
<p class="lead">The page you are trying to view is not available</p>
<div class="text-muted">
<p class="mb-1">This can happen for a few reasons:</p>
<ul>
<li>The url is invalid or has a typo</li>
<li>The page has been flagged for review by our automated abuse detection systems</li>
<li>The content may have been deleted</li>
<li>You do not have permission to view this content</li>
</ul>
</div>
</div>
<drawer />
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
export default {
components: {
"drawer": Drawer
}
}
</script>

View file

@ -0,0 +1,205 @@
<template>
<div class="profile-timeline-component">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-3 d-md-block px-md-3 px-xl-5">
<profile-sidebar
:profile="profile"
:relationship="relationship"
:user="curUser"
v-on:back="goBack"
v-on:toggletab="toggleTab"
v-on:updateRelationship="updateRelationship"
@follow="follow"
@unfollow="unfollow" />
</div>
<div class="col-md-8 px-md-5">
<component
v-bind:is="getTabComponentName()"
:key="getTabComponentName() + profile.id"
:profile="profile"
:relationship="relationship" />
</div>
</div>
<drawer />
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import ProfileFeed from './partials/profile/ProfileFeed.vue';
import ProfileSidebar from './partials/profile/ProfileSidebar.vue';
import ProfileFollowers from './partials/profile/ProfileFollowers.vue';
import ProfileFollowing from './partials/profile/ProfileFollowing.vue';
export default {
props: {
id: {
type: String
},
profileId: {
type: String
},
username: {
type: String
},
cachedProfile: {
type: Object
},
cachedUser: {
type: Object
}
},
components: {
"drawer": Drawer,
"profile-feed": ProfileFeed,
"profile-sidebar": ProfileSidebar,
"profile-followers": ProfileFollowers,
"profile-following": ProfileFollowing
},
data() {
return {
isLoaded: false,
curUser: undefined,
tab: "index",
profile: undefined,
relationship: undefined
}
},
mounted() {
this.init();
},
watch: {
'$route': 'init'
},
methods: {
init() {
this.tab = 'index';
this.isLoaded = false;
this.relationship = undefined;
this.owner = false;
if(this.cachedProfile && this.cachedUser) {
this.curUser = this.cachedUser;
this.profile = this.cachedProfile;
// this.fetchPosts();
// this.isLoaded = true;
this.fetchRelationship();
} else {
this.curUser = window._sharedData.user;
this.fetchProfile();
}
},
getTabComponentName() {
switch(this.tab) {
case 'index':
return "profile-feed";
break;
default:
return `profile-${this.tab}`;
break;
}
},
fetchProfile() {
let id = this.profileId ? this.profileId : this.id;
axios.get('/api/pixelfed/v1/accounts/' + id)
.then(res => {
this.profile = res.data;
if(res.data.id == this.curUser.id) {
this.owner = true;
// this.isLoaded = true;
// this.loaded();
// this.fetchPosts();
this.fetchRelationship();
} else {
this.owner = false;
this.fetchRelationship();
}
})
.catch(err => {
this.$router.push('/i/web/404');
});
},
fetchRelationship() {
if(this.owner) {
this.relationship = {};
this.isLoaded = true;
return;
}
axios.get('/api/v1/accounts/relationships', {
params: {
'id[]': this.profile.id
}
}).then(res => {
this.relationship = res.data[0];
this.isLoaded = true;
})
},
toggleTab(tab) {
this.tab = tab;
},
goBack() {
this.$router.go(-1);
},
unfollow() {
axios.post('/api/v1/accounts/' + this.profile.id + '/unfollow')
.then(res => {
this.$store.commit('updateRelationship', [res.data])
this.relationship = res.data;
if(this.profile.locked) {
location.reload();
}
this.profile.followers_count--;
}).catch(err => {
swal('Oops!', 'An error occured when attempting to unfollow this account.', 'error');
this.relationship.following = true;
});
},
follow() {
axios.post('/api/v1/accounts/' + this.profile.id + '/follow')
.then(res => {
this.$store.commit('updateRelationship', [res.data])
this.relationship = res.data;
if(this.profile.locked) {
this.relationship.requested = true;
}
this.profile.followers_count++;
}).catch(err => {
swal('Oops!', 'An error occured when attempting to follow this account.', 'error');
this.relationship.following = false;
});
},
updateRelationship(val) {
this.relationship = val;
}
}
}
</script>
<style lang="scss" scoped>
.profile-timeline-component {
margin-bottom: 10rem;
}
</style>

View file

@ -0,0 +1,127 @@
<template>
<div class="profile-timeline-component">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-12 col-md-8 offset-md-2 px-md-5">
<profile-followers
:profile="profile"
:relationship="relationship"
@back="goBack()"
/>
</div>
</div>
<drawer />
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import ProfileFollowers from './partials/profile/ProfileFollowers.vue';
export default {
props: {
id: {
type: String
},
profileId: {
type: String
},
username: {
type: String
},
cachedProfile: {
type: Object
},
cachedUser: {
type: Object
}
},
components: {
"drawer": Drawer,
"profile-followers": ProfileFollowers
},
data() {
return {
isLoaded: false,
curUser: undefined,
profile: undefined,
relationship: undefined
}
},
mounted() {
this.init();
},
watch: {
'$route': 'init'
},
methods: {
init() {
this.isLoaded = false;
this.relationship = undefined;
this.owner = false;
if(this.cachedProfile && this.cachedUser) {
this.curUser = this.cachedUser;
this.profile = this.cachedProfile;
this.fetchRelationship();
} else {
this.curUser = window._sharedData.user;
this.fetchProfile();
}
},
fetchProfile() {
let id = this.profileId ? this.profileId : this.id;
axios.get('/api/pixelfed/v1/accounts/' + id)
.then(res => {
this.profile = res.data;
if(res.data.id == this.curUser.id) {
this.owner = true;
// this.isLoaded = true;
// this.loaded();
// this.fetchPosts();
this.fetchRelationship();
} else {
this.owner = false;
this.fetchRelationship();
}
})
.catch(err => {
this.$router.push('/i/web/404');
});
},
fetchRelationship() {
if(this.owner) {
this.relationship = {};
this.isLoaded = true;
return;
}
axios.get('/api/v1/accounts/relationships', {
params: {
'id[]': this.profile.id
}
}).then(res => {
this.relationship = res.data[0];
this.isLoaded = true;
})
},
goBack() {
this.$router.push('/i/web/profile/' + this.profile.id);
}
}
}
</script>

View file

@ -0,0 +1,124 @@
<template>
<div class="profile-timeline-component">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-12 col-md-8 offset-md-2 px-md-5">
<profile-following
:profile="profile"
:relationship="relationship"
@back="goBack()"
/>
</div>
</div>
<drawer />
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './partials/drawer.vue';
import ProfileFollowing from './partials/profile/ProfileFollowing.vue';
export default {
props: {
id: {
type: String
},
profileId: {
type: String
},
username: {
type: String
},
cachedProfile: {
type: Object
},
cachedUser: {
type: Object
}
},
components: {
"drawer": Drawer,
"profile-following": ProfileFollowing
},
data() {
return {
isLoaded: false,
curUser: undefined,
profile: undefined,
relationship: undefined
}
},
mounted() {
this.init();
},
watch: {
'$route': 'init'
},
methods: {
init() {
this.isLoaded = false;
this.relationship = undefined;
this.owner = false;
if(this.cachedProfile && this.cachedUser) {
this.curUser = this.cachedUser;
this.profile = this.cachedProfile;
this.fetchRelationship();
} else {
this.curUser = window._sharedData.user;
this.fetchProfile();
}
},
fetchProfile() {
let id = this.profileId ? this.profileId : this.id;
axios.get('/api/pixelfed/v1/accounts/' + id)
.then(res => {
this.profile = res.data;
if(res.data.id == this.curUser.id) {
this.owner = true;
this.fetchRelationship();
} else {
this.owner = false;
this.fetchRelationship();
}
})
.catch(err => {
this.$router.push('/i/web/404');
});
},
fetchRelationship() {
if(this.owner) {
this.relationship = {};
this.isLoaded = true;
return;
}
axios.get('/api/v1/accounts/relationships', {
params: {
'id[]': this.profile.id
}
}).then(res => {
this.relationship = res.data[0];
this.isLoaded = true;
})
},
goBack() {
this.$router.push('/i/web/profile/' + this.profile.id);
}
}
}
</script>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,182 @@
<template>
<div class="discover-find-friends-component">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3">
<sidebar :user="profile" />
</div>
<div class="col-md-6 col-lg-6">
<b-breadcrumb class="font-default" :items="breadcrumbItems"></b-breadcrumb>
<h1 class="font-default">Find Friends</h1>
<!-- <p class="font-default lead">Posts from hashtags you follow</p> -->
<hr>
<b-spinner v-if="isLoading" />
<div v-if="!isLoading" class="row justify-content-center">
<div class="col-12 col-lg-10 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>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './../partials/drawer.vue';
import Sidebar from './../partials/sidebar.vue';
import StatusCard from './../partials/TimelineStatus.vue';
import ProfileCard from './../partials/profile/ProfileHoverCard.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"status-card": StatusCard,
"profile-card": ProfileCard
},
data() {
return {
isLoaded: true,
isLoading: true,
profile: window._sharedData.user,
feed: [],
popular: [],
popularAccounts: [],
popularLoaded: false,
breadcrumbItems: [
{
text: 'Discover',
href: '/i/web/discover'
},
{
text: 'Find Friends',
active: true
}
]
}
},
mounted() {
this.fetchConfig();
},
methods: {
fetchConfig() {
axios.get('/api/pixelfed/v2/discover/meta')
.then(res => {
if(res.data.friends.enabled == false) {
this.$router.push('/i/web/discover');
} else {
this.fetchPopularAccounts();
}
})
.catch(e => {
this.isLoading = false;
})
},
fetchPopular() {
axios.get('/api/pixelfed/v2/discover/account-insights')
.then(res => {
this.popular = res.data;
this.popularLoaded = true;
this.isLoading = false;
})
.catch(e => {
this.isLoading = false;
})
},
formatCount(val) {
return App.util.format.count(val);
},
timeago(ts) {
return App.util.format.timeAgo(ts);
},
fetchPopularAccounts() {
axios.get('/api/pixelfed/discover/accounts/popular')
.then(res => {
this.popularAccounts = res.data;
this.isLoading = false;
})
.catch(e => {
this.isLoading = false;
})
},
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">
.discover-find-friends-component {
.bg-stellar {
background: #7474BF;
background: -webkit-linear-gradient(to right, #348AC7, #7474BF);
background: linear-gradient(to right, #348AC7, #7474BF);
}
.bg-midnight {
background: #232526;
background: -webkit-linear-gradient(to right, #414345, #232526);
background: linear-gradient(to right, #414345, #232526);
}
.font-default {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
letter-spacing: -0.7px;
}
.active {
font-weight: 700;
}
.profile-hover-card-inner {
width: 100%;
.d-flex {
max-width: 100% !important;
}
}
}
</style>

View file

@ -0,0 +1,384 @@
<template>
<div class="discover-my-hashtags-component">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3">
<sidebar :user="profile" />
</div>
<div class="col-md-6 col-lg-6">
<b-breadcrumb class="font-default" :items="breadcrumbItems"></b-breadcrumb>
<h1 class="font-default">My Hashtags</h1>
<p class="font-default lead">Posts from hashtags you follow</p>
<hr>
<b-spinner v-if="isLoading" />
<status-card
v-if="!isLoading"
v-for="(post, index) in feed"
:key="'ti1:'+index+':'+post.id"
:profile="profile"
:status="post"
@like="likeStatus(index)"
@unlike="unlikeStatus(index)"
@share="shareStatus(index)"
@unshare="unshareStatus(index)"
@menu="openContextMenu(index)"
@mod-tools="handleModTools(index)"
@likes-modal="openLikesModal(index)"
@shares-modal="openSharesModal(index)"
@bookmark="handleBookmark(index)"
/>
<p v-if="!isLoading && tagsLoaded && feed.length == 0" class="lead">No hashtags found :(</p>
</div>
<div class="col-md-2 col-lg-3">
<div class="nav flex-column nav-pills font-default">
<a
v-for="(tag, idx) in tags"
class="nav-link"
:class="{ active: tagIndex == idx }"
href="#"
@click.prevent="toggleTag(idx)">
{{ tag }}
</a>
</div>
</div>
</div>
</div>
<context-menu
v-if="showMenu"
ref="contextMenu"
:status="feed[postIndex]"
:profile="profile"
@moderate="commitModeration"
@delete="deletePost"
@report-modal="handleReport"
/>
<likes-modal
v-if="showLikesModal"
ref="likesModal"
:status="likesModalPost"
:profile="profile"
/>
<shares-modal
v-if="showSharesModal"
ref="sharesModal"
:status="sharesModalPost"
:profile="profile"
/>
<report-modal
ref="reportModal"
:key="reportedStatusId"
:status="reportedStatus"
/>
</div>
</template>
<script type="text/javascript">
import Drawer from './../partials/drawer.vue';
import Sidebar from './../partials/sidebar.vue';
import StatusCard from './../partials/TimelineStatus.vue';
import ContextMenu from './../partials/post/ContextMenu.vue';
import LikesModal from './../partials/post/LikeModal.vue';
import SharesModal from './../partials/post/ShareModal.vue';
import ReportModal from './../partials/modal/ReportPost.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"context-menu": ContextMenu,
"likes-modal": LikesModal,
"shares-modal": SharesModal,
"report-modal": ReportModal,
"status-card": StatusCard
},
data() {
return {
isLoaded: true,
isLoading: true,
profile: window._sharedData.user,
tagIndex: 0,
tags: [],
feed: [],
tagsLoaded: false,
breadcrumbItems: [
{
text: 'Discover',
href: '/i/web/discover'
},
{
text: 'My Hashtags',
active: true
}
],
canLoadMore: true,
isFetchingMore: false,
endFeedReached: false,
postIndex: 0,
showMenu: false,
showLikesModal: false,
likesModalPost: {},
showReportModal: false,
reportedStatus: {},
reportedStatusId: 0,
showSharesModal: false,
sharesModalPost: {},
}
},
mounted() {
this.fetchHashtags();
},
methods: {
fetchHashtags() {
axios.get('/api/local/discover/tag/list')
.then(res => {
this.tags = res.data;
this.tagsLoaded = true;
if(this.tags.length) {
this.fetchTagFeed(this.tags[0]);
} else {
this.isLoading = false;
}
})
.catch(e => {
this.isLoading = false;
})
},
fetchTagFeed(hashtag) {
this.isLoading = true;
axios.get('/api/v2/discover/tag', {
params: {
hashtag: hashtag
}
})
.then(res => {
this.feed = res.data.tags.map(p => p.status);
this.isLoading = false;
})
.catch(e => {
this.isLoading = false;
})
},
toggleTag(tag) {
this.tagIndex = tag;
this.fetchTagFeed(this.tags[tag]);
},
likeStatus(index) {
let status = this.feed[index];
let state = status.favourited;
let count = status.favourites_count;
this.feed[index].favourites_count = count + 1;
this.feed[index].favourited = !status.favourited;
axios.post('/api/v1/statuses/' + status.id + '/favourite')
.then(res => {
//
}).catch(err => {
this.feed[index].favourites_count = count;
this.feed[index].favourited = false;
let el = document.createElement('p');
el.classList.add('text-left');
el.classList.add('mb-0');
el.innerHTML = '<span class="lead">We limit certain interactions to keep our community healthy and it appears that you have reached that limit. <span class="font-weight-bold">Please try again later.</span></span>';
let wrapper = document.createElement('div');
wrapper.appendChild(el);
if(err.response.status === 429) {
swal({
title: 'Too many requests',
content: wrapper,
icon: 'warning',
buttons: {
// moreInfo: {
// text: "Contact a human",
// visible: true,
// value: "more",
// className: "text-lighter bg-transparent border"
// },
confirm: {
text: "OK",
value: false,
visible: true,
className: "bg-transparent primary",
closeModal: true
}
}
})
.then((val) => {
if(val == 'more') {
location.href = '/site/contact'
}
return;
});
}
})
},
unlikeStatus(index) {
let status = this.feed[index];
let state = status.favourited;
let count = status.favourites_count;
this.feed[index].favourites_count = count - 1;
this.feed[index].favourited = !status.favourited;
axios.post('/api/v1/statuses/' + status.id + '/unfavourite')
.then(res => {
//
}).catch(err => {
this.feed[index].favourites_count = count;
this.feed[index].favourited = false;
})
},
shareStatus(index) {
let status = this.feed[index];
let state = status.reblogged;
let count = status.reblogs_count;
this.feed[index].reblogs_count = count + 1;
this.feed[index].reblogged = !status.reblogged;
axios.post('/api/v1/statuses/' + status.id + '/reblog')
.then(res => {
//
}).catch(err => {
this.feed[index].reblogs_count = count;
this.feed[index].reblogged = false;
})
},
unshareStatus(index) {
let status = this.feed[index];
let state = status.reblogged;
let count = status.reblogs_count;
this.feed[index].reblogs_count = count - 1;
this.feed[index].reblogged = !status.reblogged;
axios.post('/api/v1/statuses/' + status.id + '/unreblog')
.then(res => {
//
}).catch(err => {
this.feed[index].reblogs_count = count;
this.feed[index].reblogged = false;
})
},
openContextMenu(idx) {
this.postIndex = idx;
this.showMenu = true;
this.$nextTick(() => {
this.$refs.contextMenu.open();
});
},
commitModeration(type) {
let idx = this.postIndex;
switch(type) {
case 'addcw':
this.feed[idx].sensitive = true;
break;
case 'remcw':
this.feed[idx].sensitive = false;
break;
case 'unlist':
this.feed.splice(idx, 1);
break;
case 'spammer':
let id = this.feed[idx].account.id;
this.feed = this.feed.filter(post => {
return post.account.id != id;
});
break;
}
},
deletePost() {
this.feed.splice(this.postIndex, 1);
},
handleReport(post) {
this.reportedStatusId = post.id;
this.$nextTick(() => {
this.reportedStatus = post;
this.$refs.reportModal.open();
});
},
openLikesModal(idx) {
this.postIndex = idx;
this.likesModalPost = this.feed[this.postIndex];
this.showLikesModal = true;
this.$nextTick(() => {
this.$refs.likesModal.open();
});
},
openSharesModal(idx) {
this.postIndex = idx;
this.sharesModalPost = this.feed[this.postIndex];
this.showSharesModal = true;
this.$nextTick(() => {
this.$refs.sharesModal.open();
});
},
handleBookmark(index) {
let p = this.feed[index];
axios.post('/i/bookmark', {
item: p.id
})
.then(res => {
this.feed[index].bookmarked = !p.bookmarked;
})
.catch(err => {
this.$bvToast.toast('Cannot bookmark post at this time.', {
title: 'Bookmark Error',
variant: 'danger',
autoHideDelay: 5000
});
});
},
}
}
</script>
<style lang="scss" scoped>
.discover-my-hashtags-component {
.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;
}
.active {
font-weight: 700;
}
}
</style>

View file

@ -0,0 +1,190 @@
<template>
<div class="discover-insights-component">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3">
<sidebar :user="profile" />
</div>
<div class="col-md-6 col-lg-6">
<b-breadcrumb class="font-default" :items="breadcrumbItems"></b-breadcrumb>
<h1 class="font-default">Account Insights</h1>
<p class="font-default lead">A brief overview of your account</p>
<hr>
<div class="row">
<div class="col-12 col-md-6 mb-3">
<div class="card bg-midnight">
<div class="card-body font-default text-white">
<h1 class="display-4 mb-n2">{{ formatCount(profile.statuses_count) }}</h1>
<p class="primary lead mb-0 font-weight-bold">Posts</p>
</div>
</div>
</div>
<div class="col-12 col-md-6 mb-3">
<div class="card bg-midnight">
<div class="card-body font-default text-white">
<h1 class="display-4 mb-n2">{{ formatCount(profile.followers_count) }}</h1>
<p class="primary lead mb-0 font-weight-bold">Followers</p>
</div>
</div>
</div>
</div>
<div v-if="profile.statuses_count" class="card my-3 bg-midnight">
<div class="card-header bg-dark border-bottom border-primary text-white font-default lead">Popular Posts</div>
<div v-if="!popularLoaded" class="card-body text-white">
<b-spinner/>
</div>
<ul v-else class="list-group list-group-flush font-default text-white">
<li v-for="post in popular" class="list-group-item bg-midnight">
<div class="media align-items-center">
<img
v-if="post.media_attachments.length"
:src="post.media_attachments[0].url"
onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0'"
class="media-photo shadow">
<div class="media-body">
<p class="media-caption mb-0">{{ post.content_text.slice(0, 40) }}</p>
<p class="mb-0">
<span class="font-weight-bold">{{ post.favourites_count }} Likes</span>
<span class="mx-2">·</span>
<span class="text-muted">Posted {{ timeago(post.created_at) }} ago</span>
</p>
</div>
<button class="btn btn-primary primary font-weight-bold rounded-pill" @click="gotoPost(post)">View</button>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './../partials/drawer.vue';
import Sidebar from './../partials/sidebar.vue';
import StatusCard from './../partials/TimelineStatus.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"status-card": StatusCard
},
data() {
return {
isLoaded: true,
isLoading: true,
profile: window._sharedData.user,
feed: [],
popular: [],
popularLoaded: false,
breadcrumbItems: [
{
text: 'Discover',
href: '/i/web/discover'
},
{
text: 'Account Insights',
active: true
}
]
}
},
mounted() {
this.fetchConfig();
},
methods: {
fetchConfig() {
axios.get('/api/pixelfed/v2/discover/meta')
.then(res => {
if(res.data.insights.enabled == false) {
this.$router.push('/i/web/discover');
}
this.fetchPopular();
})
},
fetchPopular() {
axios.get('/api/pixelfed/v2/discover/account-insights')
.then(res => {
this.popular = res.data.filter(p => {
return p.favourites_count;
});
this.popularLoaded = true;
})
},
formatCount(val) {
return App.util.format.count(val);
},
timeago(ts) {
return App.util.format.timeAgo(ts);
},
gotoPost(status) {
this.$router.push({
name: 'post',
path: `/i/web/post/${status.id}`,
params: {
id: status.id,
cachedStatus: status,
cachedProfile: this.profile
}
})
}
}
}
</script>
<style lang="scss" scoped>
.discover-insights-component {
.bg-stellar {
background: #7474BF;
background: -webkit-linear-gradient(to right, #348AC7, #7474BF);
background: linear-gradient(to right, #348AC7, #7474BF);
}
.bg-midnight {
background: #232526;
background: -webkit-linear-gradient(to right, #414345, #232526);
background: linear-gradient(to right, #414345, #232526);
}
.font-default {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
letter-spacing: -0.7px;
}
.active {
font-weight: 700;
}
.media-photo {
width: 70px;
height: 70px;
border-radius: 8px;
margin-right: 2rem;
object-fit: cover;
}
.media-caption {
letter-spacing: -0.3px;
font-size: 17px;
opacity: 0.7;
}
}
</style>

View file

@ -0,0 +1,172 @@
<template>
<div class="discover-my-memories web-wrapper">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3">
<sidebar :user="profile" />
</div>
<div v-if="tabIndex === 0" class="col-md-6 col-lg-6">
<b-breadcrumb class="font-default" :items="breadcrumbItems"></b-breadcrumb>
<h1 class="font-default">My Memories</h1>
<p class="font-default lead">Posts from this day in previous years</p>
<hr>
<b-spinner v-if="!feedLoaded" />
<status-card
v-for="(post, idx) in feed"
:key="'ti0:'+idx+':'+post.id"
:profile="profile"
:status="post"
/>
<p v-if="feedLoaded && feed.length == 0" class="lead">No memories found :(</p>
</div>
<div v-else-if="tabIndex === 1" class="col-md-6 col-lg-6">
<b-breadcrumb class="font-default" :items="breadcrumbItems"></b-breadcrumb>
<h1 class="font-default">My Memories</h1>
<p class="font-default lead">Posts I've liked from this day in previous years</p>
<hr>
<b-spinner v-if="!likedLoaded" />
<status-card
v-for="(post, idx) in liked"
:key="'ti1:'+idx+':'+post.id"
:profile="profile"
:status="post"
/>
<p v-if="likedLoaded && liked.length == 0" class="lead">No memories found :(</p>
</div>
<div class="col-md-2 col-lg-3">
<div class="nav flex-column nav-pills font-default">
<a class="nav-link" :class="{ active: tabIndex == 0 }" href="#" @click.prevent="toggleTab(0)">My Posts</a>
<a class="nav-link" :class="{ active: tabIndex == 1 }" href="#" @click.prevent="toggleTab(1)">Posts I've Liked</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './../partials/drawer.vue';
import Sidebar from './../partials/sidebar.vue';
import StatusCard from './../partials/TimelineStatus.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"status-card": StatusCard
},
data() {
return {
isLoaded: true,
profile: window._sharedData.user,
curDate: undefined,
tabIndex: 0,
feedLoaded: false,
likedLoaded: false,
feed: [],
liked: [],
breadcrumbItems: [
{
text: 'Discover',
href: '/i/web/discover'
},
{
text: 'My Memories',
active: true
}
]
}
},
mounted() {
this.curDate = new Date();
this.fetchConfig();
},
methods: {
fetchConfig() {
if(
window._sharedData.hasOwnProperty('discoverMeta') &&
window._sharedData.discoverMeta
) {
this.config = window._sharedData.discoverMeta;
this.isLoaded = true;
if(this.config.memories.enabled == false) {
this.$router.push('/i/web/discover');
} else {
this.fetchMemories();
}
return;
}
axios.get('/api/pixelfed/v2/discover/meta')
.then(res => {
this.config = res.data;
this.isLoaded = true;
window._sharedData.discoverMeta = res.data;
if(res.data.memories.enabled == false) {
this.$router.push('/i/web/discover');
} else {
this.fetchMemories();
}
})
},
fetchMemories() {
axios.get('/api/pixelfed/v2/discover/memories')
.then(res => {
this.feed = res.data;
this.feedLoaded = true;
});
},
fetchLiked() {
axios.get('/api/pixelfed/v2/discover/memories?type=liked')
.then(res => {
this.liked = res.data;
this.likedLoaded = true;
});
},
toggleTab(idx) {
if(idx == 1) {
if(!this.likedLoaded) {
this.fetchLiked();
}
}
this.tabIndex = idx;
}
}
}
</script>
<style lang="scss" scoped>
.discover-my-memories {
.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;
}
.active {
font-weight: 700;
}
}
</style>

View file

@ -0,0 +1,149 @@
<template>
<div class="discover-serverfeeds-component">
<div class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3">
<sidebar :user="profile" />
</div>
<div class="col-md-6 col-lg-6">
<b-breadcrumb class="font-default" :items="breadcrumbItems"></b-breadcrumb>
<h1 class="font-default">Server Timelines</h1>
<p class="font-default lead">Browse timelines of a specific instance</p>
<hr>
<b-spinner v-if="isLoading && !initialTab" />
<status-card
v-if="!isLoading"
v-for="(post, idx) in feed"
:key="'ti1:'+idx+':'+post.id"
:profile="profile"
:status="post"
/>
<p v-if="!initialTab && !isLoading && feed.length == 0" class="lead">No posts found :(</p>
<div v-if="initialTab === true">
<p v-if="config.server.mode == 'allowlist'" class="lead">Select an instance from the menu</p>
</div>
</div>
<div class="col-md-2 col-lg-3">
<div v-if="config.server.mode === 'allowlist'" class="nav flex-column nav-pills font-default">
<a
v-for="(tag, idx) in domains"
class="nav-link"
:class="{ active: tagIndex == idx }"
href="#"
@click.prevent="toggleTag(idx)">
{{ tag }}
</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './../partials/drawer.vue';
import Sidebar from './../partials/sidebar.vue';
import StatusCard from './../partials/TimelineStatus.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"status-card": StatusCard
},
data() {
return {
isLoaded: false,
isLoading: true,
initialTab: true,
config: {},
profile: window._sharedData.user,
tagIndex: undefined,
domains: [],
feed: [],
breadcrumbItems: [
{
text: 'Discover',
href: '/i/web/discover'
},
{
text: 'Server Timelines',
active: true
}
]
}
},
mounted() {
this.fetchConfig();
},
methods: {
fetchConfig() {
axios.get('/api/pixelfed/v2/discover/meta')
.then(res => {
this.config = res.data;
if(this.config.server.enabled == false) {
this.$router.push('/i/web/discover');
}
if(this.config.server.mode === 'allowlist') {
this.domains = this.config.server.domains.split(',');
}
})
},
fetchFeed(domain) {
this.isLoading = true;
axios.get('/api/pixelfed/v2/discover/server-timeline', {
params: {
domain: domain
}
}).then(res => {
this.feed = res.data;
this.isLoading = false;
this.isLoaded = true;
})
.catch(err => {
this.feed = [];
this.tagIndex = null;
this.isLoaded = true;
this.isLoading = false;
})
},
toggleTag(tag) {
this.initialTab = false;
this.tagIndex = tag;
this.fetchFeed(this.domains[tag]);
}
}
}
</script>
<style lang="scss" scoped>
.discover-serverfeeds-component {
.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;
}
.active {
font-weight: 700;
}
}
</style>

View file

@ -0,0 +1,280 @@
<template>
<div class="discover-admin-settings-component">
<div v-if="isLoaded" class="container-fluid mt-3">
<div class="row">
<div class="col-md-4 col-lg-3">
<sidebar :user="profile" />
</div>
<div class="col-md-6 col-lg-6">
<b-breadcrumb class="font-default" :items="breadcrumbItems"></b-breadcrumb>
<h1 class="font-default">Discover Settings</h1>
<!-- <p class="font-default lead">Browse timelines of a specific instance</p> -->
<hr>
<div class="card font-default shadow-none border">
<div class="card-header">
<p class="text-center font-weight-bold mb-0">Manage Features</p>
</div>
<div class="card-body">
<div class="mb-2">
<b-form-checkbox size="lg" v-model="hashtags.enabled" name="check-button" switch class="font-weight-bold">
My Hashtags
</b-form-checkbox>
<p class="text-muted">Allow users to browse timelines of hashtags they follow</p>
</div>
<div class="mb-2">
<b-form-checkbox size="lg" v-model="memories.enabled" name="check-button" switch class="font-weight-bold">
My Memories
</b-form-checkbox>
<p class="text-muted">Allow users to access Memories, a timeline of posts they made or liked on this day in past years</p>
</div>
<div class="mb-2">
<b-form-checkbox size="lg" v-model="insights.enabled" name="check-button" switch class="font-weight-bold">
Account Insights
</b-form-checkbox>
<p class="text-muted">Allow users to access Account Insights, an overview of their account activity</p>
</div>
<div class="mb-2">
<b-form-checkbox size="lg" v-model="friends.enabled" name="check-button" switch class="font-weight-bold">
Find Friends
</b-form-checkbox>
<p class="text-muted">Allow users to access Find Friends, a directory of popular accounts</p>
</div>
<div>
<b-form-checkbox size="lg" v-model="server.enabled" name="check-button" switch class="font-weight-bold">
Server Timelines
</b-form-checkbox>
<p class="text-muted">Allow users to access Server Timelines, a timeline of public posts from a specific instance</p>
</div>
</div>
</div>
<div v-if="server.enabled" class="card font-default shadow-none border my-3">
<div class="card-header">
<p class="text-center font-weight-bold mb-0">Manage Server Timelines</p>
</div>
<div class="card-body">
<div class="mb-2">
<b-form-group label="Server Mode">
<b-form-radio v-model="server.mode" value="all" disabled>Allow any instance (Not Recommended)</b-form-radio>
<b-form-radio v-model="server.mode" value="allowlist">Limit by approved domains</b-form-radio>
</b-form-group>
<p class="text-muted">Set the allowed instances to browse</p>
</div>
<div v-if="server.mode == 'allowlist'">
<b-form-group label="Allowed Domains">
<b-form-textarea
v-model="server.domains"
placeholder="Add domains to allow here, separated by commas"
rows="3"
max-rows="6"
></b-form-textarea>
</b-form-group>
</div>
</div>
</div>
</div>
<div class="col-md-2 col-lg-3">
<button v-if="hasChanged" class="btn btn-primary btn-block primary font-weight-bold" @click="saveFeatures">Save changes</button>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import Drawer from './../partials/drawer.vue';
import Sidebar from './../partials/sidebar.vue';
import StatusCard from './../partials/TimelineStatus.vue';
export default {
components: {
"drawer": Drawer,
"sidebar": Sidebar,
"status-card": StatusCard
},
data() {
return {
isLoaded: false,
isLoading: true,
profile: window._sharedData.user,
breadcrumbItems: [
{
text: 'Discover',
href: '/i/web/discover'
},
{
text: 'Settings',
active: true
}
],
hasChanged: false,
features: {},
original: undefined,
hashtags: { enabled: undefined },
memories: { enabled: undefined },
insights: { enabled: undefined },
friends: { enabled: undefined },
server: { enabled: undefined, mode: 'allowlist', domains: '' },
}
},
watch: {
hashtags: {
deep: true,
handler: function(val, old) {
this.updateFeatures('hashtags');
},
},
memories: {
deep: true,
handler: function(val, old) {
this.updateFeatures('memories');
},
},
insights: {
deep: true,
handler: function(val, old) {
this.updateFeatures('insights');
},
},
friends: {
deep: true,
handler: function(val, old) {
this.updateFeatures('friends');
},
},
server: {
deep: true,
handler: function(val, old) {
this.updateFeatures('server');
},
}
},
beforeMount() {
if(!this.profile.is_admin) {
this.$router.push('/i/web/discover');
}
this.fetchConfig();
},
methods: {
fetchConfig() {
axios.get('/api/pixelfed/v2/discover/meta')
.then(res => {
this.original = res.data;
this.storeOriginal(res.data);
})
},
storeOriginal(data) {
this.friends.enabled = data.friends.enabled;
this.hashtags.enabled = data.hashtags.enabled;
this.insights.enabled = data.insights.enabled;
this.memories.enabled = data.memories.enabled;
this.server = {
domains: data.server.domains,
enabled: data.server.enabled,
mode: data.server.mode
};
this.isLoaded = true;
},
updateFeatures(id) {
if(!this.isLoaded) {
return;
}
let changed = false;
if(this.friends.enabled !== this.original.friends.enabled) {
changed = true;
}
if(this.hashtags.enabled !== this.original.hashtags.enabled) {
changed = true;
}
if(this.insights.enabled !== this.original.insights.enabled) {
changed = true;
}
if(this.memories.enabled !== this.original.memories.enabled) {
changed = true;
}
if(this.server.enabled !== this.original.server.enabled) {
changed = true;
}
if(this.server.domains !== this.original.server.domains) {
changed = true;
}
if(this.server.mode !== this.original.server.mode) {
changed = true;
}
// if(JSON.stringify(this.server) !== JSON.stringify(this.original.server)) {
// changed = true;
// }
this.hasChanged = changed;
},
saveFeatures() {
axios.post('/api/pixelfed/v2/discover/admin/features', {
features: {
friends: this.friends,
hashtags: this.hashtags,
insights: this.insights,
memories: this.memories,
server: this.server
}
})
.then(res => {
// let data = {
// friends: res.data.friends,
// hashtags: res.data.hashtags,
// insights: res.data.insights,
// memories: res.data.memories,
// server: res.data.server
// }
// this.original = data;
this.server = res.data.server;
this.$bvToast.toast('Successfully updated settings!', {
title: 'Discover Settings',
autoHideDelay: 5000,
appendToast: true,
variant: 'success'
})
})
}
}
}
</script>
<style lang="scss" scoped>
.discover-admin-settings-component {
.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;
}
.active {
font-weight: 700;
}
}
</style>

View file

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

View file

@ -0,0 +1,19 @@
<template>
<div class="ph-item border-0 shadow-sm" style="border-radius:15px;margin-bottom: 1rem;">
<div class="ph-col-12">
<div class="ph-row align-items-center">
<div class="ph-avatar mr-3 d-flex" style="width:50px;height:60px;border-radius: 15px;"></div>
<div class="ph-col-6 big"></div>
</div>
<div class="empty"></div>
<div class="empty"></div>
<div class="ph-picture"></div>
<div class="ph-row">
<div class="ph-col-12 empty"></div>
<div class="ph-col-12 big"></div>
<div class="ph-col-12 empty"></div>
<div class="ph-col-12"></div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,468 @@
<template>
<div class="timeline-status-component">
<div class="card shadow-sm" style="border-radius: 15px;">
<post-header
:profile="profile"
:status="status"
@menu="openMenu"
@follow="follow"
@unfollow="unfollow" />
<post-content
:profile="profile"
:status="status" />
<post-reactions
v-if="reactionBar"
:status="status"
:profile="profile"
:admin="admin"
v-on:like="like"
v-on:unlike="unlike"
v-on:share="shareStatus"
v-on:unshare="unshareStatus"
v-on:likes-modal="showLikes"
v-on:shares-modal="showShares"
v-on:toggle-comments="showComments"
v-on:bookmark="handleBookmark"
v-on:mod-tools="openModTools" />
<div v-if="showCommentDrawer" class="card-footer rounded-bottom border-0" style="background: rgba(0,0,0,0.02);z-index: 3;">
<comment-drawer
:status="status"
:profile="profile"
v-on:handle-report="handleReport"
v-on:counter-change="counterChange"
v-on:show-likes="showCommentLikes"
v-on:follow="follow"
v-on:unfollow="unfollow" />
</div>
</div>
</div>
</template>
<script type="text/javascript">
import CommentDrawer from './post/CommentDrawer.vue';
import PostHeader from './post/PostHeader.vue';
import PostContent from './post/PostContent.vue';
import PostReactions from './post/PostReactions.vue';
export default {
props: {
status: {
type: Object
},
profile: {
type: Object
},
reactionBar: {
type: Boolean,
default: true
},
useDropdownMenu: {
type: Boolean,
default: false
}
},
components: {
"comment-drawer": CommentDrawer,
"post-content": PostContent,
"post-header": PostHeader,
"post-reactions": PostReactions
},
data() {
return {
key: 1,
menuLoading: true,
sensitive: false,
showCommentDrawer: false,
isReblogging: false,
isBookmarking: false,
owner: false,
admin: false,
license: false
}
},
mounted() {
this.license = this.status.media_attachments && this.status.media_attachments.length ?
this.status
.media_attachments
.filter(m => m.hasOwnProperty('license') && m.license && m.license.hasOwnProperty('id'))
.map(m => m.license)[0] : false;
this.admin = window._sharedData.user.is_admin;
this.owner = this.status.account.id == window._sharedData.user.id;
if(this.status.reply_count && this.autoloadComments && this.status.comments_disabled === false) {
setTimeout(() => {
this.showCommentDrawer = true;
}, 1000);
}
},
computed: {
hideCounts: {
get() {
return this.$store.state.hideCounts == true;
}
},
fixedHeight: {
get() {
return this.$store.state.fixedHeight == true;
}
},
autoloadComments: {
get() {
return this.$store.state.autoloadComments == true;
}
},
newReactions: {
get() {
return this.$store.state.newReactions;
},
}
},
watch: {
status: {
deep: true,
immediate: true,
handler: function(o, n) {
this.isBookmarking = false;
}
}
},
methods: {
openMenu() {
this.$emit('menu');
},
like() {
this.$emit('like');
},
unlike() {
this.$emit('unlike');
},
showLikes() {
this.$emit('likes-modal');
},
showShares() {
this.$emit('shares-modal');
},
showComments() {
this.showCommentDrawer = !this.showCommentDrawer;
},
copyLink() {
event.currentTarget.blur();
App.util.clipboard(this.status.url);
},
shareToOther() {
if (navigator.canShare) {
navigator.share({
url: this.status.url
})
.then(() => console.log('Share was successful.'))
.catch((error) => console.log('Sharing failed', error));
} else {
swal('Not supported', 'Your current device does not support native sharing.', 'error');
}
},
counterChange(type) {
this.$emit('counter-change', type);
},
showCommentLikes(post) {
this.$emit('comment-likes-modal', post);
},
shareStatus() {
this.$emit('share');
},
unshareStatus() {
this.$emit('unshare');
},
handleReport(post) {
this.$emit('handle-report', post);
},
follow() {
this.$emit('follow');
},
unfollow() {
this.$emit('unfollow');
},
handleReblog() {
this.isReblogging = true;
if(this.status.reblogged) {
this.$emit('unshare');
} else {
this.$emit('share');
}
setTimeout(() => {
this.isReblogging = false;
}, 5000);
},
handleBookmark() {
event.currentTarget.blur();
this.isBookmarking = true;
this.$emit('bookmark');
setTimeout(() => {
this.isBookmarking = false;
}, 5000);
},
getStatusAvatar() {
if(window._sharedData.user.id == this.status.account.id) {
return window._sharedData.user.avatar;
}
return this.status.account.avatar;
},
openModTools() {
this.$emit('mod-tools');
}
}
}
</script>
<style lang="scss">
.timeline-status-component {
margin-bottom: 1rem;
.btn:focus {
box-shadow: none !important;
}
.avatar {
border-radius: 15px;
}
.VueCarousel-wrapper {
.VueCarousel-slide {
img {
object-fit: contain;
}
}
}
.status-text {
z-index: 3;
&.py-0 {
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
}
.reaction-liked-by {
font-size: 11px;
font-weight: 600;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
.timestamp,
.visibility,
.location {
color: #94a3b8;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
.invisible {
display: none;
}
.blurhash-wrapper {
img {
border-radius:0;
object-fit: cover;
}
canvas {
border-radius: 0;
}
}
.content-label-wrapper {
position: relative;
width: 100%;
height: 400px;
background-color: #000;
border-radius: 0;
overflow: hidden;
img, canvas {
max-height: 400px;
cursor: pointer;
}
}
.content-label {
margin: 0;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
z-index: 2;
border-radius: 0;
background: rgba(0, 0, 0, 0.2)
}
.rounded-bottom {
border-bottom-left-radius: 15px !important;
border-bottom-right-radius: 15px !important;
}
.card-footer {
.media {
position: relative;
.comment-border-link {
display: block;
position: absolute;
top: 40px;
left: 11px;
width: 10px;
height: calc(100% - 100px);
border-left: 4px solid transparent;
border-right: 4px solid transparent;
background-color: #E5E7EB;
background-clip: padding-box;
&:hover {
background-color: #BFDBFE;
}
}
.child-reply-form {
position: relative;
}
.comment-border-arrow {
display: block;
position: absolute;
top: -6px;
left: -33px;
width: 10px;
height: 29px;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
background-color: #E5E7EB;
background-clip: padding-box;
border-bottom: 2px solid transparent;
&:after {
content: '';
display: block;
position: absolute;
top: 25px;
left: 2px;
width: 15px;
height: 2px;
background-color: #E5E7EB;
}
}
&-status {
margin-bottom: 1.3rem;
}
&-avatar {
margin-right: 12px;
border-radius: 8px;
}
&-body {
&-comment {
width: fit-content;
padding: 0.4rem 0.7rem;
background-color: var(--comment-bg);
border-radius: 0.9rem;
&-username {
margin-bottom: 0.25rem !important;
font-size: 14px;
font-weight: 700 !important;
color: var(--body-color);
a {
color: var(--body-color);
text-decoration: none;
}
}
&-content {
margin-bottom: 0;
font-size: 16px;
}
}
&-reactions {
margin-top: 0.4rem !important;
margin-bottom: 0 !important;
color: #B8C2CC !important;
font-size: 12px;
}
}
}
}
.fixedHeight {
max-height: 400px;
.VueCarousel-wrapper {
border-radius: 15px;
}
.VueCarousel-slide {
img {
max-height: 400px;
}
}
.blurhash-wrapper {
img {
height: 400px;
max-height: 400px;
background-color: transparent;
object-fit: contain;
}
canvas {
max-height: 400px;
}
}
.content-label-wrapper {
border-radius: 15px;
}
.content-label {
height: 400px;
border-radius: 0;
}
}
}
</style>

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

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

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>

View file

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

View file

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

View file

@ -0,0 +1,100 @@
<template>
<div>
<!-- <p class="">
<router-link class="btn btn-primary primary btn-sm rounded-pill font-weight-bold btn-block" to="/i/web/whats-new"><i class="fal fa-exclamation-circle mr-1"></i> New in Metro UI 2</router-link>
</p> -->
<notifications :profile="profile" />
<!-- <div class="d-none card shadow-sm mb-3" style="border-radius: 15px;">
<div class="card-body">
<div class="d-flex justify-content-between">
<p class="text-muted">{{ $t('timeline.peopleYouMayKnow') }}</p>
<p class="text-lighter"><i class="far fa-cog"></i></p>
</div>
<div class="media-list mb-n4">
<div v-for="(account, index) in recommended" class="media align-items-center mb-3">
<img :src="account.avatar" class="avatar shadow-sm mr-3" width="40" height="40">
<div class="media-body">
<p class="lead font-weight-bold username primary">&commat;{{ account.username }}</p>
<p class="text-muted mb-0 display-name">{{ account.display_name }}</p>
</div>
<button class="btn btn-primary btn-sm follow">
{{ $t('profile.follow') }}
</button>
</div>
</div>
</div>
</div>
<div class="d-none card shadow-sm mb-3" style="border-radius: 15px;">
<div class="card-body">
<div class="d-flex justify-content-between">
<p class="text-muted">Trending</p>
<p class="text-lighter"><i class="far fa-cog"></i></p>
</div>
<div class="media-list row mb-n3">
<div v-for="(post, index) in trending" class="col-6 mb-1 p-1">
<img :src="post.url" width="100%" height="100" class="bg-white p-1 shadow-sm" style="object-fit: cover;border-radius: 15px;">
</div>
<div class="col-6 mb-1 p-1 d-flex justify-content-center align-items-center">
<button class="btn btn-link text-dark">
<i class="fal fa-plus-circle fa-lg"></i>
</button>
</div>
</div>
</div>
</div> -->
</div>
</template>
<script type="text/javascript">
import Notifications from './../sections/Notifications.vue';
export default {
components: {
"notifications": Notifications
},
data() {
return {
profile: {},
}
},
mounted() {
this.profile = window._sharedData.user;
}
}
</script>
<style lang="scss" scoped>
.avatar {
border-radius: 15px;
}
.username {
font-size: 15px;
margin-bottom: -6px;
}
.display-name {
font-size: 12px;
}
.follow {
background-color: var(--primary);
border-radius: 18px;
font-weight: 600;
padding: 5px 15px;
}
.btn-white {
background-color: #fff;
border: 1px solid #F3F4F6;
}
</style>

View file

@ -0,0 +1,726 @@
<template>
<div class="sidebar-component sticky-top d-none d-md-block">
<!-- <input type="file" class="d-none" ref="avatarUpdateRef" @change="handleAvatarUpdate()"> -->
<!-- <div class="card shadow-sm mb-3 cursor-pointer" style="border-radius: 15px;" @click="gotoMyProfile()"> -->
<div class="card shadow-sm mb-3" style="border-radius: 15px;">
<div class="card-body p-2">
<div class="media user-card user-select-none">
<div style="position: relative;">
<img :src="user.avatar" class="avatar shadow cursor-pointer" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';" @click="gotoMyProfile()">
<button class="btn btn-light btn-sm avatar-update-btn" @click="updateAvatar()">
<span class="avatar-update-btn-icon"></span>
</button>
</div>
<div class="media-body">
<p class="display-name" v-html="getDisplayName()"></p>
<p class="username primary">&commat;{{ user.username }}</p>
<p class="stats">
<span class="stats-following">
<span class="following-count">{{ formatCount(user.following_count) }}</span> Following
</span>
<span class="stats-followers">
<span class="followers-count">{{ formatCount(user.followers_count) }}</span> Followers
</span>
</p>
</div>
</div>
</div>
</div>
<div class="btn-group btn-group-lg btn-block mb-4">
<!-- <button type="button" class="btn btn-outline-primary btn-block font-weight-bold" style="border-top-left-radius: 18px;border-bottom-left-radius:18px;font-size:18px;font-weight:300!important" @click="createNewPost()">
<i class="fal fa-arrow-circle-up mr-1"></i> {{ $t('navmenu.compose') }} Post
</button> -->
<router-link to="/i/web/compose" class="btn btn-primary btn-block font-weight-bold">
<i class="fal fa-arrow-circle-up mr-1"></i> {{ $t('navmenu.compose') }} Post
</router-link>
<button type="button" class="btn btn-outline-primary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown" aria-expanded="false">
<span class="sr-only">Toggle Dropdown</span>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item font-weight-bold" href="/i/collections/create">Create Collection</a>
<a v-if="hasStories" class="dropdown-item font-weight-bold" href="/i/stories/new">Create Story</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item font-weight-bold" href="/settings/home">Account Settings</a>
</div>
</div>
<!-- <router-link to="/i/web/compose" class="btn btn-primary btn-lg btn-block mb-4 shadow-sm font-weight-bold">
<i class="far fa-plus-square mr-1"></i> {{ $t('navmenu.compose') }}
</router-link> -->
<div class="sidebar-sticky shadow-sm">
<ul class="nav flex-column">
<li class="nav-item">
<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> -->
<a
class="nav-link text-center"
href="/i/web"
:class="[ $route.path == '/i/web' ? 'router-link-exact-active active' : '' ]"
@click.prevent="goToFeed('home')">
<div class="icon text-lighter"><i class="far fa-home fa-lg"></i></div>
<div class="small">{{ $t('navmenu.homeFeed') }}</div>
</a>
<!-- <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> -->
<a
v-if="hasLocalTimeline"
class="nav-link text-center"
href="/i/web/timeline/local"
:class="[ $route.path == '/i/web/timeline/local' ? 'router-link-exact-active active' : '' ]"
@click.prevent="goToFeed('local')">
<div class="icon text-lighter"><i class="fas fa-stream fa-lg"></i></div>
<div class="small">{{ $t('navmenu.localFeed') }}</div>
</a>
<!-- <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> -->
<a
v-if="hasNetworkTimeline"
class="nav-link text-center"
href="/i/web/timeline/global"
:class="[ $route.path == '/i/web/timeline/global' ? 'router-link-exact-active active' : '' ]"
@click.prevent="goToFeed('global')">
<div class="icon text-lighter"><i class="far fa-globe fa-lg"></i></div>
<div class="small">{{ $t('navmenu.globalFeed') }}</div>
</a>
</div>
<hr class="mb-0" style="margin-top: -5px;opacity: 0.4;" />
</li>
<!-- <li class="nav-item">
</li>
<li class="nav-item">
</li> -->
<!-- <li v-for="(link, index) in links" class="nav-item">
<router-link class="nav-link" :to="link.path">
<span v-if="link.icon" class="icon text-lighter"><i :class="[ link.icon ]"></i></span>
{{ link.name }}
</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">
<router-link class="nav-link" to="/i/web/groups">
<span class="icon text-lighter"><i class="far fa-layer-group"></i></span>
{{ $t('navmenu.groups') }}
</router-link>
</li> -->
<li v-if="hasLiveStreams" class="nav-item">
<router-link class="nav-link d-flex justify-content-between align-items-center" to="/i/web/livestreams">
<span>
<span class="icon text-lighter">
<i class="far fa-record-vinyl"></i>
</span>
Livestreams
</span>
</router-link>
</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>
<!-- <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">
<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>
<!-- <router-link class="nav-link" to="/i/web/settings">
<span class="icon text-lighter">
<i class="far fa-cog"></i>
</span>
{{ $t('navmenu.settings') }}
</router-link> -->
</li>
<!-- <li class="nav-item">
<router-link class="nav-link" to="/i/web/drive">
<span class="icon text-lighter">
<i class="far fa-cloud-upload"></i>
</span>
{{ $t('navmenu.drive') }}
</router-link>
</li> -->
<!-- <li class="nav-item">
<router-link class="nav-link" to="/i/web/settings">
<span class="icon text-lighter">
<i class="fas fa-cog"></i>
</span>
{{ $t('navmenu.settings') }}
</router-link>
</li>
<li class="nav-item">
<a class="nav-link" href="/i/web/help">
<span class="icon text-lighter">
<i class="fas fa-info-circle"></i>
</span>
Help
</a>
</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="/?force_old_ui=1">
<span class="icon text-lighter">
<i class="fas fa-chevron-left"></i>
</span>
{{ $t('navmenu.backToPreviousDesign') }}
</a>
</li>
<!-- <li class="nav-item">
<router-link class="nav-link" to="/i/web/?a=feed">
<span class="fas fa-stream pr-2 text-lighter"></span>
Feed
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/i/web/discover">
<span class="fas fa-compass pr-2 text-lighter"></span>
Discover
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/i/web/stories">
<span class="fas fa-history pr-2 text-lighter"></span>
Stories
</router-link>
</li> -->
</ul>
</div>
<!-- <div class="sidebar-sitelinks">
<a href="/site/about">{{ $t('navmenu.about') }}</a>
<a href="/site/language">{{ $t('navmenu.language') }}</a>
<a href="/site/terms">{{ $t('navmenu.privacy') }}</a>
<a href="/site/terms">{{ $t('navmenu.terms') }}</a>
</div> -->
<div class="sidebar-attribution pr-3 d-flex justify-content-between align-items-center">
<router-link to="/i/web/language">
<i class="fal fa-language fa-2x" alt="Select a language"></i>
</router-link>
<a href="/site/help" class="font-weight-bold">{{ $t('navmenu.help') }}</a>
<a href="/site/privacy" class="font-weight-bold">{{ $t('navmenu.privacy') }}</a>
<a href="/site/terms" class="font-weight-bold">{{ $t('navmenu.terms') }}</a>
<a href="https://pixelfed.org" class="font-weight-bold powered-by">Powered by Pixelfed</a>
</div>
<!-- <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"
>
<div class="d-flex align-items-center justify-content-center">
<div
v-if="avatarUpdateIndex === 0"
class="py-5 user-select-none cursor-pointer"
@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="avatarUpdateFile" 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="avatarUpdateClose()">Cancel</button>
<button class="btn btn-primary primary font-weight-bold btn-block mt-0">Upload</button>
</div>
</div>
</div>
</b-modal> -->
<!-- <b-modal
ref="createPostModal"
centered
hide-footer
header-class="py-2"
body-class="p-0 w-100 h-100"
title-class="w-100 text-center pl-4 font-weight-bold"
title-tag="p"
title="Create New Post"
>
<compose-simple />
</b-modal> -->
<update-avatar ref="avatarUpdate" :user="user" />
</div>
</template>
<script type="text/javascript">
import { mapGetters } from 'vuex'
// import ComposeSimple from './../sections/ComposeSimple.vue';
import UpdateAvatar from './modal/UpdateAvatar.vue';
export default {
props: {
user: {
type: Object,
default: (function() {
return {
avatar: '/storage/avatars/default.jpg',
username: false,
display_name: '',
following_count: 0,
followers_count: 0
};
})
},
links: {
type: Array,
default: function() {
return [
// {
// name: "Home",
// path: "/i/web",
// icon: "fas fa-home"
// },
// {
// name: "Local",
// path: "/i/web/timeline/local",
// icon: "fas fa-stream"
// },
// {
// name: "Global",
// path: "/i/web/timeline/global",
// icon: "far fa-globe"
// },
// {
// name: "Audiences",
// path: "/i/web/discover",
// icon: "far fa-circle-notch"
// },
{
name: "Discover",
path: "/i/web/discover",
icon: "fas fa-compass"
},
// {
// name: "Events",
// path: "/i/events",
// icon: "far fa-calendar-alt"
// },
{
name: "Groups",
path: "/i/web/groups",
icon: "far fa-user-friends"
},
// {
// name: "Live",
// path: "/i/web/?t=live",
// icon: "far fa-play"
// },
// {
// name: "Marketplace",
// path: "/i/web/marketplace",
// icon: "far fa-shopping-cart"
// },
// {
// name: "Stories",
// path: "/i/web/?t=stories",
// icon: "fas fa-history"
// },
{
name: "Videos",
path: "/i/web/videos",
icon: "far fa-video"
}
];
}
}
},
components: {
// ComposeSimple,
UpdateAvatar
},
computed: {
...mapGetters([
'getCustomEmoji'
])
},
data() {
return {
loaded: false,
hasLocalTimeline: true,
hasNetworkTimeline: false,
hasLiveStreams: false,
hasStories: false,
}
},
mounted() {
if(window.App.config.features.hasOwnProperty('timelines')) {
this.hasLocalTimeline = App.config.features.timelines.local;
this.hasNetworkTimeline = App.config.features.timelines.network;
//this.hasLiveStreams = App.config.ab.hls == true;
}
if(window.App.config.features.hasOwnProperty('stories')) {
this.hasStories = App.config.features.stories;
}
// if(!this.user.username) {
// this.user = window._sharedData.user;
// }
// setTimeout(() => {
// this.user = window._sharedData.curUser;
// this.loaded = true;
// }, 300);
},
methods: {
getDisplayName() {
let self = this;
let profile = this.user;
let dn = profile.display_name;
if(!dn) {
return profile.username;
}
if(dn.includes(':')) {
let re = /(<a?)?:\w+:(\d{18}>)?/g;
let un = dn.replaceAll(re, function(em) {
let shortcode = em.slice(1, em.length - 1);
let emoji = self.getCustomEmoji.filter(e => {
return e.shortcode == shortcode;
});
return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`: em;
});
return un;
} else {
return dn;
}
},
gotoMyProfile() {
let user = this.user;
this.$router.push({
name: 'profile',
path: `/i/web/profile/${user.id}`,
params: {
id: user.id,
cachedProfile: user,
cachedUser: user
}
})
},
formatCount(count = 0, locale = 'en-GB', notation = 'compact') {
return new Intl.NumberFormat(locale, { notation: notation , compactDisplay: "short" }).format(count);
},
updateAvatar() {
event.currentTarget.blur();
// swal('update avatar', 'test', 'success');
this.$refs.avatarUpdate.open();
},
createNewPost() {
this.$refs.createPostModal.show();
},
goToFeed(feed) {
const curPath = this.$route.path;
switch(feed) {
case 'home':
if(curPath == '/i/web') {
this.$emit('refresh');
} else {
this.$router.push('/i/web');
}
break;
case 'local':
if(curPath == '/i/web/timeline/local') {
this.$emit('refresh');
} else {
this.$router.push({ name: 'timeline', params: { scope: 'local' }});
}
break;
case 'global':
if(curPath == '/i/web/timeline/global') {
this.$emit('refresh');
} else {
this.$router.push({ name: 'timeline', params: { scope: 'global' }});
}
break;
}
}
}
}
</script>
<style lang="scss">
.sidebar-component {
.sidebar-sticky {
background-color: var(--card-bg);
border-radius: 15px;
}
&.sticky-top {
top: 90px;
}
.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;
&:hover {
background-color: var(--light-hover-bg);
}
.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;
}
}
&:first-child {
.nav-link {
.small {
font-weight: 700;
}
&:first-child {
border-top-left-radius: 15px;
}
&:last-child {
border-top-right-radius: 15px;
}
}
}
&:is(:last-child) {
.nav-link {
margin-bottom: 0;
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
}
}
}
.sidebar-heading {
font-size: .75rem;
text-transform: uppercase;
}
.user-card {
align-items: center;
.avatar {
width: 75px;
height: 75px;
border-radius: 15px;
margin-right: 0.8rem;
border: 1px solid var(--border-color);
}
.avatar-update-btn {
position: absolute;
right: 12px;
bottom: 0;
width: 20px;
height: 20px;
background: rgba(255,255,255,0.9);
border: 1px solid #dee2e6 !important;
padding: 0;
border-radius: 50rem;
&-icon {
font-family: 'Font Awesome 5 Free';
font-weight: 400;
-webkit-font-smoothing: antialiased;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
&:before {
content: "\F013";
}
}
}
.username {
font-weight: 600;
font-size: 13px;
margin-bottom: 0;
}
.display-name {
color: var(--body-color);
line-height: 0.8;
font-size: 14px;
font-weight: 800 !important;
user-select: all;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
margin-bottom: 0;
word-break: break-all;
}
.stats {
margin-top: 0;
margin-bottom: 0;
font-size: 12px;
user-select: none;
.stats-following {
margin-right: 0.8rem;
}
.following-count,
.followers-count {
font-weight: 800;
}
}
}
.btn-primary {
background-color: var(--primary);
&.router-link-exact-active {
opacity: 0.5;
pointer-events: none;
cursor: unset;
}
}
.sidebar-sitelinks {
margin-top: 1rem;
display: flex;
justify-content: space-between;
padding: 0 2rem;
a {
font-size: 12px;
color: #B8C2CC;
}
.active {
color: #212529;
font-weight: 600;
}
}
.sidebar-attribution {
margin-top: 0.5rem;
font-size: 10px;
color: #B8C2CC;
padding-left: 2rem;
a {
color: #B8C2CC !important;
&.powered-by {
opacity: 0.5;
}
}
}
}
</style>

View file

@ -0,0 +1,234 @@
<template>
<div class="media mb-2 align-items-center px-3 shadow-sm py-2 bg-white" style="border-radius: 15px;">
<a href="#" @click.prevent="getProfileUrl(n.account)" v-b-tooltip.hover :title="n.account.acct">
<img class="mr-3 shadow-sm" style="border-radius:8px" :src="n.account.avatar" alt="" width="40" height="40" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
</a>
<div class="media-body font-weight-light">
<div v-if="n.type == 'favourite'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.liked') }} <a class="font-weight-bold" :href="displayPostUrl(n.status)" @click.prevent="getPostUrl(n.status)">post</a>.
</p>
</div>
<div v-else-if="n.type == 'comment'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.commented') }} <a class="font-weight-bold" :href="displayPostUrl(n.status)" @click.prevent="getPostUrl(n.status)">post</a>.
</p>
</div>
<!-- <div v-else-if="n.type == 'group:comment'">
<p class="my-0">
<a href="#" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.commented') }} <a class="font-weight-bold" v-bind:href="n.group_post_url">{{ $('notifications.groupPost') }}</a>.
</p>
</div> -->
<div v-else-if="n.type == 'story:react'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.reacted') }} <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">story</a>.
</p>
</div>
<div v-else-if="n.type == 'story:comment'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.commented') }} <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">{{ $t('notifications.story') }}</a>.
</p>
</div>
<div v-else-if="n.type == 'mention'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)">{{ $t('notifications.mentioned') }}</a> {{ $t('notifications.you') }}.
</p>
</div>
<div v-else-if="n.type == 'follow'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.followed') }} {{ $t('notifications.you') }}.
</p>
</div>
<div v-else-if="n.type == 'share'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.shared') }} <a class="font-weight-bold" :href="displayPostUrl(n.status)" @click.prevent="getPostUrl(n.status)">{{ $t('notifications.post') }}</a>.
</p>
</div>
<div v-else-if="n.type == 'modlog'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">{{truncate(n.account.username)}}</a> {{ $t('notifications.updatedA') }} <a class="font-weight-bold" v-bind:href="n.modlog.url">{{ $t('notifications.modlog') }}</a>.
</p>
</div>
<div v-else-if="n.type == 'tagged'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.tagged') }} <a class="font-weight-bold" v-bind:href="n.tagged.post_url">{{ $t('notifications.post') }}</a>.
</p>
</div>
<div v-else-if="n.type == 'direct'">
<p class="my-0">
<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.sentA') }} <router-link class="font-weight-bold" :to="'/i/web/direct/thread/'+n.account.id">{{ $t('notifications.dm') }}</router-link>.
</p>
</div>
<div v-else-if="n.type == 'group.join.approved'">
<p class="my-0">
{{ $t('notifications.yourApplication') }} <a :href="n.group.url" class="font-weight-bold text-dark text-break" :title="n.group.name">{{truncate(n.group.name)}}</a> {{ $t('notifications.applicationApproved') }}
</p>
</div>
<div v-else-if="n.type == 'group.join.rejected'">
<p class="my-0">
{{ $t('notifications.yourApplication') }} <a :href="n.group.url" class="font-weight-bold text-dark text-break" :title="n.group.name">{{truncate(n.group.name)}}</a> {{ $t('notifications.applicationRejected') }}
</p>
</div>
<div v-else>
<p class="my-0 d-flex justify-content-between align-items-center">
<span class="font-weight-bold">Notification</span>
<span style="font-size:8px;">e_{{ n.type }}::{{ n.id }}</span>
</p>
</div>
<div class="align-items-center">
<span class="small text-muted" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</span>
</div>
</div>
<div>
<div v-if="n.status && n.status && n.status.media_attachments && n.status.media_attachments.length">
<a href="#" @click.prevent="getPostUrl(n.status)">
<img :src="n.status.media_attachments[0].preview_url" width="32px" height="32px">
</a>
</div>
<div v-else-if="n.status && n.status.parent && n.status.parent.media_attachments && n.status.parent.media_attachments.length">
<a :href="n.status.parent.url">
<img :src="n.status.parent.media_attachments[0].preview_url" width="32px" height="32px">
</a>
</div>
<!-- <div v-else-if="n.status && n.status.parent && n.status.parent.media_attachments && n.status.parent.media_attachments.length">
<a :href="n.status.parent.url">
<img :src="n.status.parent.media_attachments[0].preview_url" width="32px" height="32px">
</a>
</div> -->
<!-- <div v-else>
<a v-if="viewContext(n) != '/'" class="btn btn-outline-primary py-0 font-weight-bold" href="#" @click.prevent="viewContext(n)">View</a>
</div> -->
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
n: {
type: Object
}
},
data() {
return {
profile: window._sharedData.user
};
},
methods: {
truncate(text, limit = 30) {
if(text.length <= limit) {
return text;
}
return text.slice(0, limit) + '...'
},
timeAgo(ts) {
let date = Date.parse(ts);
let seconds = Math.floor((new Date() - date) / 1000);
let interval = Math.floor(seconds / 31536000);
if (interval >= 1) {
return interval + "y";
}
interval = Math.floor(seconds / 604800);
if (interval >= 1) {
return interval + "w";
}
interval = Math.floor(seconds / 86400);
if (interval >= 1) {
return interval + "d";
}
interval = Math.floor(seconds / 3600);
if (interval >= 1) {
return interval + "h";
}
interval = Math.floor(seconds / 60);
if (interval >= 1) {
return interval + "m";
}
return Math.floor(seconds) + "s";
},
mentionUrl(status) {
let username = status.account.username;
let id = status.id;
return '/p/' + username + '/' + id;
},
viewContext(n) {
switch(n.type) {
case 'follow':
return this.getProfileUrl(n.account);
return n.account.url;
break;
case 'mention':
return n.status.url;
break;
case 'like':
case 'favourite':
case 'comment':
return this.getPostUrl(n.status);
// return n.status.url;
break;
case 'tagged':
return n.tagged.post_url;
break;
case 'direct':
return '/account/direct/t/'+n.account.id;
break
}
return '/';
},
displayProfileUrl(account) {
return `/i/web/profile/${account.id}`;
},
displayPostUrl(status) {
return `/i/web/post/${status.id}`;
},
getProfileUrl(account) {
this.$router.push({
name: 'profile',
path: `/i/web/profile/${account.id}`,
params: {
id: account.id,
cachedProfile: account,
cachedUser: this.profile
}
});
},
getPostUrl(status) {
this.$router.push({
name: 'post',
path: `/i/web/post/${status.id}`,
params: {
id: status.id,
cachedStatus: status,
cachedProfile: this.profile
}
});
}
}
}
</script>

View file

@ -0,0 +1,200 @@
<template>
<div class="story-carousel-component">
<div v-if="canShow" class="d-flex story-carousel-component-wrapper" style="overflow-y: auto;z-index: 3;">
<a class="col-4 col-lg-3 col-xl-2 px-1 text-dark text-decoration-none" href="/i/stories/new" style="max-width: 120px;">
<template v-if="selfStory && selfStory.length">
<div
class="story-wrapper text-white shadow-sm mb-3"
:style="{ background: `linear-gradient(rgba(0,0,0,0.2),rgba(0,0,0,0.4)), url(${selfStory[0].latest.preview_url})`, backgroundSize: 'cover', backgroundPosition: 'center'}"
style="width: 100%;height:200px;border-radius:15px;">
<div class="story-wrapper-blur d-flex flex-column align-items-center justify-content-between" style="display: block;width: 100%;height:100%;">
<p class="mb-4"></p>
<p class="mb-0"><i class="fal fa-plus-circle fa-2x"></i></p>
<p class="font-weight-bold">My Story</p>
</div>
</div>
</template>
<template v-else>
<div
class="story-wrapper text-white shadow-sm d-flex flex-column align-items-center justify-content-between"
style="width: 100%;height:200px;border-radius:15px;">
<p class="mb-4"></p>
<p class="mb-0"><i class="fal fa-plus-circle fa-2x"></i></p>
<p class="font-weight-bold">{{ $t('story.add') }}</p>
</div>
</template>
</a>
<div v-for="(story, index) in stories" class="col-4 col-lg-3 col-xl-2 px-1" style="max-width: 120px;">
<template v-if="story.hasOwnProperty('url')">
<a class="story" :href="story.url">
<div
v-if="story.latest && story.latest.type == 'photo'"
class="shadow-sm story-wrapper"
:class="{ seen: story.seen }"
:style="{ background: `linear-gradient(rgba(0,0,0,0.2),rgba(0,0,0,0.4)), url(${story.latest.preview_url})`, backgroundSize: 'cover', backgroundPosition: 'center'}">
<div class="story-wrapper-blur" style="display: block;width: 100%;height:100%;position:relative;">
<div class="px-2" style="display: block;width: 100%;bottom:0;position: absolute;">
<p class="mt-3 mb-0">
<img :src="story.avatar" width="30" height="30" class="avatar" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">
</p>
<p class="mb-0"></p>
<p class="username font-weight-bold small text-truncate">
{{ story.username }}
</p>
</div>
</div>
</div>
<div
v-else
class="shadow-sm story-wrapper">
<div class="px-2" style="display: block;width: 100%;bottom:0;position: absolute;">
<p class="mt-3 mb-0">
<img :src="story.avatar" width="30" height="30" class="avatar">
</p>
<p class="mb-0"></p>
<p class="username font-weight-bold small text-truncate">
{{ story.username }}
</p>
</div>
</div>
</a>
</template>
<template v-else>
<div
class="story shadow-sm story-wrapper seen"
:style="{ background: `linear-gradient(rgba(0,0,0,0.01),rgba(0,0,0,0.04))`}">
<div class="story-wrapper-blur" style="display: block;width: 100%;height:100%;position:relative;">
<div class="px-2" style="display: block;width: 100%;bottom:0;position: absolute;">
<p class="mt-3 mb-0">
</p>
<p class="mb-0"></p>
<p class="username font-weight-bold small text-truncate">
</p>
</div>
</div>
</div>
</template>
</div>
<template v-if="selfStory && selfStory.length && stories.length < 2">
<div v-for="i in 5" class="col-4 col-lg-3 col-xl-2 px-1 story" style="max-width: 120px;">
<div
class="shadow-sm story-wrapper seen"
:style="{ background: `linear-gradient(rgba(0,0,0,0.01),rgba(0,0,0,0.04))`}">
<div class="story-wrapper-blur" style="display: block;width: 100%;height:100%;position:relative;">
<div class="px-2" style="display: block;width: 100%;bottom:0;position: absolute;">
<p class="mt-3 mb-0">
</p>
<p class="mb-0"></p>
<p class="username font-weight-bold small text-truncate">
</p>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
profile: {
type: Object
}
},
data() {
return {
canShow: false,
stories: [],
selfStory: undefined
}
},
mounted() {
this.fetchStories();
},
methods: {
fetchStories() {
axios.get('/api/web/stories/v1/recent')
.then(res => {
if(res.data && res.data.length) {
this.selfStory = res.data.filter(s => s.pid == this.profile.id);
let activeStories = res.data.filter(s => s.pid !== this.profile.id);
this.stories = activeStories;
this.canShow = true;
if(!activeStories || !activeStories.length || activeStories.length < 5) {
this.stories.push(...Array(5 - activeStories.length).keys())
}
}
})
}
}
}
</script>
<style lang="scss">
.story-carousel-component {
&-wrapper {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0 !important
}
}
.story {
&-wrapper {
display: block;
position: relative;
width: 100%;
height: 200px;
border-radius: 15px;
margin-bottom: 1rem;
background: #b24592;
background: -webkit-linear-gradient(to right, #b24592, #f15f79);
background: linear-gradient(to right, #b24592, #f15f79);
overflow: hidden;
border: 1px solid var(--border-color);
.username {
color: #fff;
}
.avatar {
border-radius: 6px;
margin-bottom: 5px;
}
&.seen {
opacity: 30%;
}
&-blur {
border-radius: 15px;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
}
}
}
}
.force-dark-mode {
.story-wrapper {
&.seen {
opacity: 50%;
background: linear-gradient(rgba(255,255,255,0.12),rgba(255,255,255,0.14)) !important;
}
}
}
</style>

View file

@ -0,0 +1,206 @@
<template>
<div class="discover-feed-component">
<section class="mt-3 mb-5 section-explore">
<b-breadcrumb class="font-default" :items="breadcrumbItems"></b-breadcrumb>
<div class="profile-timeline">
<div class="row p-0 mt-5">
<div class="col-12 mb-4 d-flex justify-content-between align-items-center">
<p class="d-block d-md-none h1 font-weight-bold mb-0 font-default">Trending</p>
<p class="d-none d-md-block display-4 font-weight-bold mb-0 font-default">Trending</p>
<div>
<div class="btn-group trending-range">
<button @click="rangeToggle('daily')" :class="range == 'daily' ? 'btn py-1 font-weight-bold px-3 text-uppercase btn-sm btn-danger':'btn py-1 font-weight-bold px-3 text-uppercase btn-sm btn-outline-danger'">Today</button>
<button @click="rangeToggle('monthly')" :class="range == 'monthly' ? 'btn py-1 font-weight-bold px-3 text-uppercase btn-sm btn-danger':'btn py-1 font-weight-bold px-3 text-uppercase btn-sm btn-outline-danger'">This month</button>
<button @click="rangeToggle('yearly')" :class="range == 'yearly' ? 'btn py-1 font-weight-bold px-3 text-uppercase btn-sm btn-danger':'btn py-1 font-weight-bold px-3 text-uppercase btn-sm btn-outline-danger'">This year</button>
</div>
</div>
</div>
</div>
<div v-if="!loading" class="row p-0 px-lg-3">
<div v-if="trending.length" v-for="(s, index) in trending" class="col-6 col-lg-4 col-xl-3 p-1">
<a class="card info-overlay card-md-border-0" :href="s.url" @click.prevent="goToPost(s)">
<div class="square square-next">
<div v-if="s.sensitive" class="square-content">
<div class="info-overlay-text-label">
<h5 class="text-white m-auto font-weight-bold">
<span>
<span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
</span>
</h5>
</div>
<blur-hash-canvas
width="32"
height="32"
:hash="s.media_attachments[0].blurhash"
/>
</div>
<div v-else class="square-content">
<blur-hash-image
width="32"
height="32"
:hash="s.media_attachments[0].blurhash"
:src="s.media_attachments[0].preview_url"
/>
</div>
<div class="info-overlay-text">
<div class="text-white m-auto">
<p class="info-overlay-text-field font-weight-bold">
<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
<span class="d-flex-inline">{{formatCount(s.favourites_count)}}</span>
</p>
<p class="info-overlay-text-field font-weight-bold">
<span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
<span class="d-flex-inline">{{formatCount(s.reply_count)}}</span>
</p>
<p class="mb-0 info-overlay-text-field font-weight-bold">
<span class="far fa-sync fa-lg p-2 d-flex-inline"></span>
<span class="d-flex-inline">{{formatCount(s.reblogs_count)}}</span>
</p>
</div>
</div>
</div>
</a>
</div>
<div v-else class="col-12 d-flex align-items-center justify-content-center bg-light border" style="min-height: 40vh;">
<div class="h2">No posts found :(</div>
</div>
</div>
<div v-else class="row p-0 px-lg-3">
<div class="col-12 d-flex align-items-center justify-content-center" style="min-height: 40vh;">
<b-spinner size="lg" />
</div>
</div>
</div>
</section>
</div>
</template>
<script type="text/javascript">
export default {
props: {
profile: {
type: Object
}
},
data() {
return {
loading: true,
trending: [],
range: 'daily',
breadcrumbItems: [
{
text: 'Discover',
href: '/i/web/discover'
},
{
text: 'Trending',
active: true
}
]
}
},
mounted() {
this.loadTrending();
},
methods: {
fetchData() {
axios.get('/api/pixelfed/v2/discover/posts')
.then((res) => {
this.posts = res.data.posts.filter(r => r != null);
this.recommendedLoading = false;
});
},
loadTrending() {
this.loading = true;
axios.get('/api/pixelfed/v2/discover/posts/trending', {
params: {
range: this.range
}
})
.then(res => {
let data = res.data.filter(r => {
return r !== null;
});
this.trending = data.filter(t => t.sensitive == false);
if(this.range == 'daily' && data.length == 0) {
this.range = 'yearly';
this.loadTrending();
}
this.loading = false;
});
},
formatCount(s) {
return App.util.format.count(s);
},
goToPost(status) {
this.$router.push({
name: 'post',
params: {
id: status.id,
cachedStatus: status,
cachedProfile: this.profile
}
})
},
rangeToggle(range) {
event.currentTarget.blur();
this.range = range;
this.loadTrending();
}
}
}
</script>
<style lang="scss">
.discover-feed-component {
.font-default {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
letter-spacing: -0.7px;
}
.info-overlay {
border-radius: 15px !important;
}
.square-next {
img,
.info-overlay-text {
border-radius: 15px !important;
}
}
.trending-range {
.btn {
&:hover:not(.btn-danger) {
background-color: #fca5a5
}
}
}
.info-overlay-text-field {
font-size: 13.5px;
margin-bottom: 2px;
@media (min-width: 768px) {
font-size: 20px;
margin-bottom: 15px;
}
}
}
</style>

View file

@ -0,0 +1,582 @@
<template>
<div class="timeline-section-component">
<div v-if="!isLoaded">
<status-placeholder />
<status-placeholder />
<status-placeholder />
<status-placeholder />
</div>
<div v-else>
<status
v-for="(status, index) in feed"
:key="'pf_feed:' + status.id + ':idx:' + index + ':fui:' + forceUpdateIdx"
:status="status"
:profile="profile"
v-on:like="likeStatus(index)"
v-on:unlike="unlikeStatus(index)"
v-on:share="shareStatus(index)"
v-on:unshare="unshareStatus(index)"
v-on:menu="openContextMenu(index)"
v-on:counter-change="counterChange(index, $event)"
v-on:likes-modal="openLikesModal(index)"
v-on:shares-modal="openSharesModal(index)"
v-on:follow="follow(index)"
v-on:unfollow="unfollow(index)"
v-on:comment-likes-modal="openCommentLikesModal"
v-on:handle-report="handleReport"
v-on:bookmark="handleBookmark(index)"
v-on:mod-tools="handleModTools(index)"
/>
<div v-if="showLoadMore" class="text-center">
<button
class="btn btn-primary rounded-pill font-weight-bold"
@click="tryToLoadMore">
Load more
</button>
</div>
<div v-if="canLoadMore">
<intersect @enter="enterIntersect">
<status-placeholder style="margin-bottom: 10rem;"/>
</intersect>
</div>
<div v-if="!isLoaded && feed.length && endFeedReached" style="margin-bottom: 50vh">
<div class="card card-body shadow-sm mb-3" style="border-radius: 15px;">
<p class="display-4 text-center"></p>
<p class="lead mb-0 text-center">You have reached the end of this feed</p>
</div>
</div>
<timeline-onboarding
v-if="scope == 'home' && !feed.length"
:profile="profile"
v-on:update-profile="updateProfile" />
<empty-timeline v-if="isLoaded && scope !== 'home' && !feed.length" />
</div>
<context-menu
v-if="showMenu"
ref="contextMenu"
:status="feed[postIndex]"
:profile="profile"
v-on:moderate="commitModeration"
v-on:delete="deletePost"
v-on:report-modal="handleReport"
v-on:edit="handleEdit"
/>
<likes-modal
v-if="showLikesModal"
ref="likesModal"
:status="likesModalPost"
:profile="profile"
/>
<shares-modal
v-if="showSharesModal"
ref="sharesModal"
:status="sharesModalPost"
:profile="profile"
/>
<report-modal
ref="reportModal"
:key="reportedStatusId"
:status="reportedStatus"
/>
<post-edit-modal
ref="editModal"
v-on:update="mergeUpdatedPost"
/>
</div>
</template>
<script type="text/javascript">
import StatusPlaceholder from './../partials/StatusPlaceholder.vue';
import Status from './../partials/TimelineStatus.vue';
import Intersect from 'vue-intersect';
import ContextMenu from './../partials/post/ContextMenu.vue';
import LikesModal from './../partials/post/LikeModal.vue';
import SharesModal from './../partials/post/ShareModal.vue';
import ReportModal from './../partials/modal/ReportPost.vue';
import EmptyTimeline from './../partials/placeholders/EmptyTimeline.vue'
import TimelineOnboarding from './../partials/placeholders/TimelineOnboarding.vue'
import PostEditModal from './../partials/post/PostEditModal.vue';
export default {
props: {
scope: {
type: String,
default: "home"
},
profile: {
type: Object
},
refresh: {
type: Boolean,
default: false
}
},
components: {
"intersect": Intersect,
"status-placeholder": StatusPlaceholder,
"status": Status,
"context-menu": ContextMenu,
"likes-modal": LikesModal,
"shares-modal": SharesModal,
"report-modal": ReportModal,
"empty-timeline": EmptyTimeline,
"timeline-onboarding": TimelineOnboarding,
"post-edit-modal": PostEditModal
},
data() {
return {
isLoaded: false,
feed: [],
ids: [],
max_id: 0,
canLoadMore: true,
showLoadMore: false,
loadMoreTimeout: undefined,
loadMoreAttempts: 0,
isFetchingMore: false,
endFeedReached: false,
postIndex: 0,
showMenu: false,
showLikesModal: false,
likesModalPost: {},
showReportModal: false,
reportedStatus: {},
reportedStatusId: 0,
showSharesModal: false,
sharesModalPost: {},
forceUpdateIdx: 0
}
},
mounted() {
if(window.App.config.features.hasOwnProperty('timelines')) {
if(this.scope == 'local' && !window.App.config.features.timelines.local) {
swal('Error', 'Cannot load this timeline', 'error');
return;
};
if(this.scope == 'network' && !window.App.config.features.timelines.network) {
swal('Error', 'Cannot load this timeline', 'error');
return;
};
}
this.fetchTimeline();
},
methods: {
getScope() {
switch(this.scope) {
case 'local':
return 'public'
break;
case 'global':
return 'network'
break;
default:
return 'home';
break;
}
},
fetchTimeline(scrollToTop = false) {
let url = `/api/pixelfed/v1/timelines/${this.getScope()}`;
axios.get(url, {
params: {
max_id: this.max_id,
limit: 6
}
}).then(res => {
let ids = res.data.map(p => {
if(p && p.hasOwnProperty('relationship')) {
this.$store.commit('updateRelationship', [p.relationship]);
}
return p.id
});
this.isLoaded = true;
if(res.data.length == 0) {
return;
}
this.ids = ids;
this.max_id = Math.min(...ids);
this.feed = res.data;
if(res.data.length !== 6) {
this.canLoadMore = false;
this.showLoadMore = true;
}
})
.then(() => {
if(scrollToTop) {
this.$nextTick(() => {
window.scrollTo({
top: 0,
left: 0,
behavior: 'smooth'
});
this.$emit('refreshed');
});
}
})
},
enterIntersect() {
if(this.isFetchingMore) {
return;
}
this.isFetchingMore = true;
let url = `/api/pixelfed/v1/timelines/${this.getScope()}`;
axios.get(url, {
params: {
max_id: this.max_id,
limit: 6
}
}).then(res => {
if(!res.data.length) {
this.endFeedReached = true;
this.canLoadMore = false;
this.isFetchingMore = false;
}
setTimeout(() => {
res.data.forEach(p => {
if(this.ids.indexOf(p.id) == -1) {
if(this.max_id > p.id) {
this.max_id = p.id;
}
this.ids.push(p.id);
this.feed.push(p);
if(p && p.hasOwnProperty('relationship')) {
this.$store.commit('updateRelationship', [p.relationship]);
}
}
});
this.isFetchingMore = false;
}, 100);
});
},
tryToLoadMore() {
this.loadMoreAttempts++;
if(this.loadMoreAttempts >= 3) {
this.showLoadMore = false;
}
this.showLoadMore = false;
this.canLoadMore = true;
this.loadMoreTimeout = setTimeout(() => {
this.canLoadMore = false;
this.showLoadMore = true;
}, 5000);
},
likeStatus(index) {
let status = this.feed[index];
let state = status.favourited;
let count = status.favourites_count;
this.feed[index].favourites_count = count + 1;
this.feed[index].favourited = !status.favourited;
axios.post('/api/v1/statuses/' + status.id + '/favourite')
.then(res => {
//
}).catch(err => {
this.feed[index].favourites_count = count;
this.feed[index].favourited = false;
let el = document.createElement('p');
el.classList.add('text-left');
el.classList.add('mb-0');
el.innerHTML = '<span class="lead">We limit certain interactions to keep our community healthy and it appears that you have reached that limit. <span class="font-weight-bold">Please try again later.</span></span>';
let wrapper = document.createElement('div');
wrapper.appendChild(el);
if(err.response.status === 429) {
swal({
title: 'Too many requests',
content: wrapper,
icon: 'warning',
buttons: {
// moreInfo: {
// text: "Contact a human",
// visible: true,
// value: "more",
// className: "text-lighter bg-transparent border"
// },
confirm: {
text: "OK",
value: false,
visible: true,
className: "bg-transparent primary",
closeModal: true
}
}
})
.then((val) => {
if(val == 'more') {
location.href = '/site/contact'
}
return;
});
}
})
},
unlikeStatus(index) {
let status = this.feed[index];
let state = status.favourited;
let count = status.favourites_count;
this.feed[index].favourites_count = count - 1;
this.feed[index].favourited = !status.favourited;
axios.post('/api/v1/statuses/' + status.id + '/unfavourite')
.then(res => {
//
}).catch(err => {
this.feed[index].favourites_count = count;
this.feed[index].favourited = false;
})
},
openContextMenu(idx) {
this.postIndex = idx;
this.showMenu = true;
this.$nextTick(() => {
this.$refs.contextMenu.open();
});
},
handleModTools(idx) {
this.postIndex = idx;
this.showMenu = true;
this.$nextTick(() => {
this.$refs.contextMenu.openModMenu();
});
},
openLikesModal(idx) {
this.postIndex = idx;
this.likesModalPost = this.feed[this.postIndex];
this.showLikesModal = true;
this.$nextTick(() => {
this.$refs.likesModal.open();
});
},
openSharesModal(idx) {
this.postIndex = idx;
this.sharesModalPost = this.feed[this.postIndex];
this.showSharesModal = true;
this.$nextTick(() => {
this.$refs.sharesModal.open();
});
},
commitModeration(type) {
let idx = this.postIndex;
switch(type) {
case 'addcw':
this.feed[idx].sensitive = true;
break;
case 'remcw':
this.feed[idx].sensitive = false;
break;
case 'unlist':
this.feed.splice(idx, 1);
break;
case 'spammer':
let id = this.feed[idx].account.id;
this.feed = this.feed.filter(post => {
return post.account.id != id;
});
break;
}
},
deletePost() {
this.feed.splice(this.postIndex, 1);
},
counterChange(index, type) {
switch(type) {
case 'comment-increment':
this.feed[index].reply_count = this.feed[index].reply_count + 1;
break;
case 'comment-decrement':
this.feed[index].reply_count = this.feed[index].reply_count - 1;
break;
}
},
openCommentLikesModal(post) {
this.likesModalPost = post;
this.showLikesModal = true;
this.$nextTick(() => {
this.$refs.likesModal.open();
});
},
shareStatus(index) {
let status = this.feed[index];
let state = status.reblogged;
let count = status.reblogs_count;
this.feed[index].reblogs_count = count + 1;
this.feed[index].reblogged = !status.reblogged;
axios.post('/api/v1/statuses/' + status.id + '/reblog')
.then(res => {
//
}).catch(err => {
this.feed[index].reblogs_count = count;
this.feed[index].reblogged = false;
})
},
unshareStatus(index) {
let status = this.feed[index];
let state = status.reblogged;
let count = status.reblogs_count;
this.feed[index].reblogs_count = count - 1;
this.feed[index].reblogged = !status.reblogged;
axios.post('/api/v1/statuses/' + status.id + '/unreblog')
.then(res => {
//
}).catch(err => {
this.feed[index].reblogs_count = count;
this.feed[index].reblogged = false;
})
},
handleReport(post) {
this.reportedStatusId = post.id;
this.$nextTick(() => {
this.reportedStatus = post;
this.$refs.reportModal.open();
});
},
handleBookmark(index) {
let p = this.feed[index];
axios.post('/i/bookmark', {
item: p.id
})
.then(res => {
this.feed[index].bookmarked = !p.bookmarked;
})
.catch(err => {
// this.feed[index].bookmarked = false;
this.$bvToast.toast('Cannot bookmark post at this time.', {
title: 'Bookmark Error',
variant: 'danger',
autoHideDelay: 5000
});
});
},
follow(index) {
// this.feed[index].relationship.following = true;
axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/follow')
.then(res => {
this.$store.commit('updateRelationship', [res.data]);
this.updateProfile({ following_count: this.profile.following_count + 1 });
this.feed[index].account.followers_count = this.feed[index].account.followers_count + 1;
}).catch(err => {
swal('Oops!', 'An error occured when attempting to follow this account.', 'error');
this.feed[index].relationship.following = false;
});
},
unfollow(index) {
// this.feed[index].relationship.following = false;
axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/unfollow')
.then(res => {
this.$store.commit('updateRelationship', [res.data]);
this.updateProfile({ following_count: this.profile.following_count - 1 });
this.feed[index].account.followers_count = this.feed[index].account.followers_count - 1;
}).catch(err => {
swal('Oops!', 'An error occured when attempting to unfollow this account.', 'error');
this.feed[index].relationship.following = true;
});
},
updateProfile(delta) {
this.$emit('update-profile', delta);
},
handleRefresh() {
this.isLoaded = false;
this.feed = [];
this.ids = [];
this.max_id = 0;
this.canLoadMore = true;
this.showLoadMore = false;
this.loadMoreTimeout = undefined;
this.loadMoreAttempts = 0;
this.isFetchingMore = false;
this.endFeedReached = false;
this.postIndex = 0;
this.showMenu = false;
this.showLikesModal = false;
this.likesModalPost = {};
this.showReportModal = false;
this.reportedStatus = {};
this.reportedStatusId = 0;
this.showSharesModal = false;
this.sharesModalPost = {};
this.$nextTick(() => {
this.fetchTimeline(true);
});
},
handleEdit(status) {
this.$refs.editModal.show(status);
},
mergeUpdatedPost(post) {
this.feed = this.feed.map(p => {
if(p.id == post.id) {
p = post;
}
return p;
});
this.$nextTick(() => {
this.forceUpdateIdx++;
});
}
},
watch: {
'refresh': 'handleRefresh'
},
beforeDestroy() {
clearTimeout(this.loadMoreTimeout);
}
}
</script>

View file

@ -21,11 +21,21 @@ Array.from(document.querySelectorAll('.pagination .page-link'))
.filter(el => el.textContent === '« Previous' || el.textContent === 'Next »')
.forEach(el => el.textContent = (el.textContent === 'Next »' ? '' :''));
Vue.component(
'admin-autospam',
require('./../components/admin/AdminAutospam.vue').default
);
Vue.component(
'admin-directory',
require('./../components/admin/AdminDirectory.vue').default
);
Vue.component(
'admin-reports',
require('./../components/admin/AdminReports.vue').default
);
Vue.component(
'instances-component',
require('./../components/admin/AdminInstances.vue').default

View file

@ -1,4 +1,5 @@
window.Vue = require('vue');
import Vue from 'vue';
window.Vue = Vue;
import BootstrapVue from 'bootstrap-vue'
import InfiniteLoading from 'vue-infinite-loading';
import Loading from 'vue-loading-overlay';

View file

@ -0,0 +1,189 @@
<template>
<div>
<nav class="navbar navbar-expand navbar-light navbar-laravel shadow-none border-bottom sticky-top py-1">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="/" title="Logo">
<img src="/img/pixelfed-icon-color.svg" height="30px" class="px-2" loading="eager">
<span class="font-weight-bold mb-0 d-none d-sm-block" style="font-size:20px;">{{ config.site.name }}</span>
</a>
<div v-if="loaded && loggedIn" class="collapse navbar-collapse">
<ul class="navbar-nav d-none d-md-block mx-auto">
<form class="form-inline search-bar" method="get" action="/i/results">
<input class="form-control form-control-sm" name="q" placeholder="Search ..." aria-label="search" autocomplete="off" required style="line-height: 0.6;width:200px">
</form>
</ul>
<div class="ml-auto">
<ul class="navbar-nav">
<div class="d-none d-md-block">
<li class="nav-item px-md-2">
<a class="nav-link font-weight-bold text-muted" href="/discover" title="Discover" data-toggle="tooltip" data-placement="bottom">
<i class="far fa-compass fa-lg"></i>
</a>
</li>
</div>
<div class="d-none d-md-block">
<li class="nav-item px-md-2">
<a class="nav-link font-weight-bold text-muted" href="/account/activity" title="Notifications" data-toggle="tooltip" data-placement="bottom">
<span class="fa-layers fa-fw">
<i class="far fa-bell fa-lg"></i>
<span class="fa-layers-counter mr-n2 mt-n1" style="background:Tomato"></span>
</span>
</a>
</li>
</div>
<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" data-placement="bottom">
<i class="far fa-user fa-lg text-muted"></i>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
<div class="dropdown-item font-weight-bold cursor-pointer" onclick="App.util.compose.post()">
<span class="fas fa-plus-square pr-2 text-lighter"></span>
New Post
</div>
<div class="dropdown-divider"></div>
<a class="dropdown-item font-weight-bold" href="/">
<span class="fas fa-home pr-2 text-lighter"></span>
Home
</a>
<a class="dropdown-item font-weight-bold" href="/timeline/public">
<span class="fas fa-stream pr-2 text-lighter"></span>
Local
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item font-weight-bold" href="/i/me">
<span class="far fa-user pr-2 text-lighter"></span>
My Profile
</a>
<a class="d-block d-md-none dropdown-item font-weight-bold" href="/discover">
<span class="far fa-compass pr-2 text-lighter"></span>
Discover
</a>
<a class="dropdown-item font-weight-bold" href="/notifications">
<span class="far fa-bell pr-2 text-lighter"></span>
Notifications
</a>
<a class="dropdown-item font-weight-bold" href="/settings/home">
<span class="fas fa-cog pr-2 text-lighter"></span>
Settings
</a>
<div v-if="curUser.is_admin">
<div class="dropdown-divider"></div>
<a class="dropdown-item font-weight-bold" href="/i/admin/dashboard">
<span class="fas fa-shield-alt fa-sm pr-2 text-lighter"></span>
Admin
</a>
</div>
<div class="dropdown-divider"></div>
<a class="dropdown-item font-weight-bold" href="/logout" @click="logout">
<span class="fas fa-sign-out-alt pr-2"></span>
Logout
</a>
</div>
</li>
</ul>
</div>
</div>
<div v-if="loaded && !loggedIn" class="collapse navbar-collapse">
<ul class="navbar-nav ml-auto">
<li>
<a class="nav-link font-weight-bold text-primary" href="/login" title="Login">
Login
</a>
</li>
<li v-if="config.open_registration">
<a class="nav-link font-weight-bold" href="/register" title="Register">
Register
</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</template>
<style type="text/css" scoped>
.fa-layers-counter, .fa-layers-text {
display: inline-block;
position: absolute;
text-align: center;
}
.fa-layers .far {
bottom: 0;
left: 0;
margin: auto;
position: absolute;
right: 0;
top: 0;
}
.fa-fw {
text-align: center;
width: 1.25em;
}
.fa-layers {
display: inline-block;
height: 1em;
position: relative;
text-align: center;
vertical-align: -.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: .25em;
right: 0;
text-overflow: ellipsis;
top: 0;
-webkit-transform: scale(.25);
transform: scale(.25);
-webkit-transform-origin: top right;
transform-origin: top right;
}
</style>
<script type="text/javascript">
export default {
data() {
return {
config: window.App.config,
curUser: {},
loggedIn: false,
loaded: false
}
},
mounted() {
this.timeout();
},
methods: {
logout() {
axios.post('/logout')
.then(res => {
window.location.href = '/';
});
},
timeout() {
let self = this;
setTimeout(function() {
self.curUser = window._sharedData.curUser;
self.loggedIn = self.curUser.hasOwnProperty('username');
self.loaded = true;
}, 1000);
}
}
}
</script>

819
resources/assets/js/spa.js vendored Normal file
View file

@ -0,0 +1,819 @@
require('./polyfill');
import Vue from 'vue';
window.Vue = Vue;
import VueRouter from "vue-router";
import Vuex from "vuex";
import { sync } from "vuex-router-sync";
import BootstrapVue from 'bootstrap-vue'
import InfiniteLoading from 'vue-infinite-loading';
import Loading from 'vue-loading-overlay';
import VueTimeago from 'vue-timeago';
import VueCarousel from 'vue-carousel';
import VueBlurHash from 'vue-blurhash';
import VueMasonry from 'vue-masonry-css';
import VueI18n from 'vue-i18n';
window.pftxt = require('twitter-text');
import 'vue-blurhash/dist/vue-blurhash.css'
window.filesize = require('filesize');
import swal from 'sweetalert';
window._ = require('lodash');
window.Popper = require('popper.js').default;
window.pixelfed = window.pixelfed || {};
window.$ = window.jQuery = require('jquery');
require('bootstrap');
window.axios = require('axios');
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
require('readmore-js');
window.blurhash = require("blurhash");
$('[data-toggle="tooltip"]').tooltip()
let token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
} else {
console.error('CSRF token not found.');
}
Vue.use(VueRouter);
Vue.use(Vuex);
Vue.use(VueBlurHash);
Vue.use(VueCarousel);
Vue.use(BootstrapVue);
Vue.use(InfiniteLoading);
Vue.use(Loading);
Vue.use(VueMasonry);
Vue.use(VueI18n);
Vue.use(VueTimeago, {
name: 'Timeago',
locale: 'en'
});
Vue.component(
'navbar',
require('./../components/partials/navbar.vue').default
);
Vue.component(
'notification-card',
require('./components/NotificationCard.vue').default
);
Vue.component(
'photo-presenter',
require('./components/presenter/PhotoPresenter.vue').default
);
Vue.component(
'video-presenter',
require('./components/presenter/VideoPresenter.vue').default
);
Vue.component(
'photo-album-presenter',
require('./components/presenter/PhotoAlbumPresenter.vue').default
);
Vue.component(
'video-album-presenter',
require('./components/presenter/VideoAlbumPresenter.vue').default
);
Vue.component(
'mixed-album-presenter',
require('./components/presenter/MixedAlbumPresenter.vue').default
);
Vue.component(
'post-menu',
require('./components/PostMenu.vue').default
);
// Vue.component(
// 'announcements-card',
// require('./components/AnnouncementsCard.vue').default
// );
Vue.component(
'story-component',
require('./components/StoryTimelineComponent.vue').default
);
const HomeComponent = () => import(/* webpackChunkName: "home.chunk" */ "./../components/Home.vue");
const ComposeComponent = () => import(/* webpackChunkName: "compose.chunk" */ "./../components/Compose.vue");
const PostComponent = () => import(/* webpackChunkName: "post.chunk" */ "./../components/Post.vue");
const ProfileComponent = () => import(/* webpackChunkName: "profile.chunk" */ "./../components/Profile.vue");
const MemoriesComponent = () => import(/* webpackChunkName: "discover~memories.chunk" */ "./../components/discover/Memories.vue");
const MyHashtagComponent = () => import(/* webpackChunkName: "discover~myhashtags.chunk" */ "./../components/discover/Hashtags.vue");
const AccountInsightsComponent = () => import(/* webpackChunkName: "daci.chunk" */ "./../components/discover/Insights.vue");
const DiscoverFindFriendsComponent = () => import(/* webpackChunkName: "discover~findfriends.chunk" */ "./../components/discover/FindFriends.vue");
const DiscoverServerFeedComponent = () => import(/* webpackChunkName: "discover~serverfeed.chunk" */ "./../components/discover/ServerFeed.vue");
const DiscoverSettingsComponent = () => import(/* webpackChunkName: "discover~settings.chunk" */ "./../components/discover/Settings.vue");
const DiscoverComponent = () => import(/* webpackChunkName: "discover.chunk" */ "./../components/Discover.vue");
const NotificationsComponent = () => import(/* webpackChunkName: "notifications.chunk" */ "./../components/Notifications.vue");
const DirectComponent = () => import(/* webpackChunkName: "dms.chunk" */ "./../components/Direct.vue");
const DirectMessageComponent = () => import(/* webpackChunkName: "dms~message.chunk" */ "./../components/DirectMessage.vue");
const ProfileFollowersComponent = () => import(/* webpackChunkName: "profile~followers.bundle" */ "./../components/ProfileFollowers.vue");
const ProfileFollowingComponent = () => import(/* webpackChunkName: "profile~following.bundle" */ "./../components/ProfileFollowing.vue");
const HashtagComponent = () => import(/* webpackChunkName: "discover~hashtag.bundle" */ "./../components/Hashtag.vue");
const NotFoundComponent = () => import(/* webpackChunkName: "error404.bundle" */ "./../components/NotFound.vue");
// const HelpComponent = () => import(/* webpackChunkName: "help.bundle" */ "./../components/HelpComponent.vue");
// const KnowledgebaseComponent = () => import(/* webpackChunkName: "kb.bundle" */ "./../components/Knowledgebase.vue");
// const AboutComponent = () => import(/* webpackChunkName: "about.bundle" */ "./../components/About.vue");
// const ContactComponent = () => import(/* webpackChunkName: "contact.bundle" */ "./../components/Contact.vue");
const LanguageComponent = () => import(/* webpackChunkName: "i18n.bundle" */ "./../components/Language.vue");
// const PrivacyComponent = () => import(/* webpackChunkName: "static~privacy.bundle" */ "./../components/Privacy.vue");
// const TermsComponent = () => import(/* webpackChunkName: "static~tos.bundle" */ "./../components/Terms.vue");
const ChangelogComponent = () => import(/* webpackChunkName: "changelog.bundle" */ "./../components/Changelog.vue");
// import LiveComponent from "./../components/Live.vue";
// import LivestreamsComponent from "./../components/Livestreams.vue";
// import LivePlayerComponent from "./../components/LivePlayer.vue";
// import LiveHelpComponent from "./../components/LiveHelp.vue";
// import DriveComponent from "./../components/Drive.vue";
// import SettingsComponent from "./../components/Settings.vue";
// import ProfileComponent from "./components/ProfileNext.vue";
// import VideosComponent from "./../components/Videos.vue";
// import GroupsComponent from "./../components/Groups.vue";
const router = new VueRouter({
mode: "history",
linkActiveClass: "active",
routes: [
{
path: "/i/web/timeline/:scope",
name: 'timeline',
component: HomeComponent,
props: true
},
// {
// path: "/i/web/timeline/local",
// component: LocalTimeline
// },
// {
// path: "/i/web/timeline/global",
// component: GlobalTimeline
// },
// {
// path: "/i/web/drive",
// name: 'drive',
// component: DriveComponent,
// props: true
// },
// {
// path: "/i/web/groups",
// name: 'groups',
// component: GroupsComponent,
// props: true
// },
{
path: "/i/web/post/:id",
name: 'post',
component: PostComponent,
props: true
},
// {
// path: "/i/web/profile/:id/live",
// component: LivePlayerComponent,
// props: true
// },
{
path: "/i/web/profile/:id/followers",
name: 'profile-followers',
component: ProfileFollowersComponent,
props: true
},
{
path: "/i/web/profile/:id/following",
name: 'profile-following',
component: ProfileFollowingComponent,
props: true
},
{
path: "/i/web/profile/:id",
name: 'profile',
component: ProfileComponent,
props: true
},
// {
// path: "/i/web/videos",
// component: VideosComponent
// },
{
path: "/i/web/discover",
component: DiscoverComponent
},
// {
// path: "/i/web/stories",
// component: HomeComponent
// },
// {
// path: "/i/web/settings/*",
// component: SettingsComponent,
// props: true
// },
// {
// path: "/i/web/settings",
// component: SettingsComponent
// },
{
path: "/i/web/compose",
component: ComposeComponent
},
{
path: "/i/web/notifications",
component: NotificationsComponent
},
{
path: "/i/web/direct/thread/:accountId",
component: DirectMessageComponent,
props: true
},
{
path: "/i/web/direct",
component: DirectComponent
},
// {
// path: "/i/web/kb/:id",
// name: "kb",
// component: KnowledgebaseComponent,
// props: true
// },
{
path: "/i/web/hashtag/:id",
name: "hashtag",
component: HashtagComponent,
props: true
},
// {
// path: "/i/web/help",
// component: HelpComponent
// },
// {
// path: "/i/web/about",
// component: AboutComponent
// },
// {
// path: "/i/web/contact",
// component: ContactComponent
// },
{
path: "/i/web/language",
component: LanguageComponent
},
// {
// path: "/i/web/privacy",
// component: PrivacyComponent
// },
// {
// path: "/i/web/terms",
// component: TermsComponent
// },
{
path: "/i/web/whats-new",
component: ChangelogComponent
},
{
path: "/i/web/discover/my-memories",
component: MemoriesComponent
},
{
path: "/i/web/discover/my-hashtags",
component: MyHashtagComponent
},
{
path: "/i/web/discover/account-insights",
component: AccountInsightsComponent
},
{
path: "/i/web/discover/find-friends",
component: DiscoverFindFriendsComponent
},
{
path: "/i/web/discover/server-timelines",
component: DiscoverServerFeedComponent
},
{
path: "/i/web/discover/settings",
component: DiscoverSettingsComponent
},
// {
// path: "/i/web/livestreams",
// component: LivestreamsComponent
// },
// {
// path: "/i/web/live/help",
// component: LiveHelpComponent
// },
// {
// path: "/i/web/live/player",
// component: LivePlayerComponent
// },
// {
// path: "/i/web/live",
// component: LiveComponent
// },
{
path: "/i/web",
component: HomeComponent,
props: true
},
{
path: "/i/web/*",
component: NotFoundComponent,
props: true
},
],
scrollBehavior(to, from, savedPosition) {
if (to.hash) {
return {
selector: `[id='${to.hash.slice(1)}']`
};
} else {
return { x: 0, y: 0 };
}
}
});
function lss(name, def) {
let key = 'pf_m2s.' + name;
let ls = window.localStorage;
if(ls.getItem(key)) {
let val = ls.getItem(key);
if(['pl', 'color-scheme'].includes(name)) {
return val;
}
return ['true', true].includes(val);
}
return def;
}
const store = new Vuex.Store({
state: {
version: 1,
hideCounts: lss('hc', false),
autoloadComments: lss('ac', true),
newReactions: lss('nr', true),
fixedHeight: lss('fh', false),
profileLayout: lss('pl', 'grid'),
showDMPrivacyWarning: lss('dmpwarn', true),
relationships: {},
emoji: [],
colorScheme: lss('color-scheme', 'system'),
},
getters: {
getVersion: state => {
return state.version;
},
getHideCounts: state => {
return state.hideCounts;
},
getAutoloadComments: state => {
return state.autoloadComments;
},
getNewReactions: state => {
return state.newReactions;
},
getFixedHeight: state => {
return state.fixedHeight;
},
getProfileLayout: state => {
return state.profileLayout;
},
getRelationship: (state) => (id) => {
// let rel = state.relationships[id];
// if(!rel || !rel.hasOwnProperty('id')) {
// return axios.get('/api/pixelfed/v1/accounts/relationships', {
// params: {
// 'id[]': id
// }
// })
// .then(res => {
// let relationship = res.data;
// // Vue.set(state.relationships, relationship.id, relationship);
// state.commit('updateRelationship', res.data[0]);
// return res.data[0];
// })
// .catch(err => {
// return {};
// })
// } else {
// return state.relationships[id];
// }
return state.relationships[id];
},
getCustomEmoji: state => {
return state.emoji;
},
getColorScheme: state => {
return state.colorScheme;
},
getShowDMPrivacyWarning: state => {
return state.showDMPrivacyWarning;
}
},
mutations: {
setVersion(state, value) {
state.version = value;
},
setHideCounts(state, value) {
localStorage.setItem('pf_m2s.hc', value);
state.hideCounts = value;
},
setAutoloadComments(state, value) {
localStorage.setItem('pf_m2s.ac', value);
state.autoloadComments = value;
},
setNewReactions(state, value) {
localStorage.setItem('pf_m2s.nr', value);
state.newReactions = value;
},
setFixedHeight(state, value) {
localStorage.setItem('pf_m2s.fh', value);
state.fixedHeight = value;
},
setProfileLayout(state, value) {
localStorage.setItem('pf_m2s.pl', value);
state.profileLayout = value;
},
updateRelationship(state, relationships) {
relationships.forEach((relationship) => {
Vue.set(state.relationships, relationship.id, relationship)
})
},
updateCustomEmoji(state, emojis) {
state.emoji = emojis;
},
setColorScheme(state, value) {
if(state.colorScheme == value) {
return;
}
localStorage.setItem('pf_m2s.color-scheme', value);
state.colorScheme = value;
const name = value == 'system' ? '' : (value == 'light' ? 'force-light-mode' : 'force-dark-mode');
document.querySelector("body").className = name;
if(name != 'system') {
const payload = name == 'force-dark-mode' ? { dark_mode: 'on' } : {};
axios.post('/settings/labs', payload);
}
},
setShowDMPrivacyWarning(state, value) {
localStorage.setItem('pf_m2s.dmpwarn', value);
state.showDMPrivacyWarning = value;
}
},
});
let i18nMessages = {
en: require('./i18n/en.json'),
ar: require('./i18n/ar.json'),
ca: require('./i18n/ca.json'),
de: require('./i18n/de.json'),
el: require('./i18n/el.json'),
es: require('./i18n/es.json'),
eu: require('./i18n/eu.json'),
fr: require('./i18n/fr.json'),
he: require('./i18n/he.json'),
gd: require('./i18n/gd.json'),
gl: require('./i18n/gl.json'),
id: require('./i18n/id.json'),
it: require('./i18n/it.json'),
ja: require('./i18n/ja.json'),
nl: require('./i18n/nl.json'),
pl: require('./i18n/pl.json'),
pt: require('./i18n/pt.json'),
ru: require('./i18n/ru.json'),
uk: require('./i18n/uk.json'),
vi: require('./i18n/vi.json'),
};
let locale = document.querySelector('html').getAttribute('lang');
const i18n = new VueI18n({
locale: locale, // set locale
fallbackLocale: 'en',
messages: i18nMessages
});
sync(store, router);
const App = new Vue({
el: '#content',
i18n,
router,
store
});
axios.get('/api/v1/custom_emojis')
.then(res => {
if(res && res.data && res.data.length) {
store.commit('updateCustomEmoji', res.data);
}
});
if(store.state.colorScheme) {
const name = store.state.colorScheme == 'system' ? '' : (store.state.colorScheme == 'light' ? 'force-light-mode' : 'force-dark-mode');
if(name != 'system') {
document.querySelector("body").className = name;
}
}
pixelfed.readmore = () => {
$('.read-more').each(function(k,v) {
let el = $(this);
let attr = el.attr('data-readmore');
if(typeof attr !== typeof undefined && attr !== false) {
return;
}
el.readmore({
collapsedHeight: 45,
heightMargin: 48,
moreLink: '<a href="#" class="d-block small font-weight-bold text-dark text-center">Show more</a>',
lessLink: '<a href="#" class="d-block small font-weight-bold text-dark text-center">Show less</a>',
});
});
};
try {
document.createEvent("TouchEvent");
$('body').addClass('touch');
} catch (e) {
}
window.App = window.App || {};
// window.App.redirect = function() {
// document.querySelectorAll('a').forEach(function(i,k) {
// let a = i.getAttribute('href');
// if(a && a.length > 5 && a.startsWith('https://')) {
// let url = new URL(a);
// if(url.host !== window.location.host && url.pathname !== '/i/redirect') {
// i.setAttribute('href', '/i/redirect?url=' + encodeURIComponent(a));
// }
// }
// });
// }
// window.App.boot = function() {
// new Vue({ el: '#content'});
// }
// window.addEventListener("load", () => {
// if ("serviceWorker" in navigator) {
// navigator.serviceWorker.register("/sw.js");
// }
// });
window.App.util = {
compose: {
post: (function() {
let path = window.location.pathname;
let whitelist = [
'/',
'/timeline/public'
];
if(whitelist.includes(path)) {
$('#composeModal').modal('show');
} else {
window.location.href = '/?a=co';
}
}),
circle: (function() {
console.log('Unsupported method.');
}),
collection: (function() {
console.log('Unsupported method.');
}),
loop: (function() {
console.log('Unsupported method.');
}),
story: (function() {
console.log('Unsupported method.');
}),
},
time: (function() {
return new Date;
}),
version: 1,
format: {
count: (function(count = 0, locale = 'en-GB', notation = 'compact') {
if(count < 1) {
return 0;
}
return new Intl.NumberFormat(locale, { notation: notation , compactDisplay: "short" }).format(count);
}),
timeAgo: (function(ts) {
let date = Date.parse(ts);
let seconds = Math.floor((new Date() - date) / 1000);
let interval = Math.floor(seconds / 63072000);
if (interval < 0) {
return "0s";
}
if (interval >= 1) {
return interval + "y";
}
interval = Math.floor(seconds / 604800);
if (interval >= 1) {
return interval + "w";
}
interval = Math.floor(seconds / 86400);
if (interval >= 1) {
return interval + "d";
}
interval = Math.floor(seconds / 3600);
if (interval >= 1) {
return interval + "h";
}
interval = Math.floor(seconds / 60);
if (interval >= 1) {
return interval + "m";
}
return Math.floor(seconds) + "s";
}),
timeAhead: (function(ts, short = true) {
let date = Date.parse(ts);
let diff = date - Date.parse(new Date());
let seconds = Math.floor((diff) / 1000);
let interval = Math.floor(seconds / 63072000);
if (interval >= 1) {
return interval + (short ? "y" : " years");
}
interval = Math.floor(seconds / 604800);
if (interval >= 1) {
return interval + (short ? "w" : " weeks");
}
interval = Math.floor(seconds / 86400);
if (interval >= 1) {
return interval + (short ? "d" : " days");
}
interval = Math.floor(seconds / 3600);
if (interval >= 1) {
return interval + (short ? "h" : " hours");
}
interval = Math.floor(seconds / 60);
if (interval >= 1) {
return interval + (short ? "m" : " minutes");
}
return Math.floor(seconds) + (short ? "s" : " seconds");
}),
rewriteLinks: (function(i) {
let tag = i.innerText;
if(i.href.startsWith(window.location.origin)) {
return i.href;
}
if(tag.startsWith('#') == true) {
tag = '/discover/tags/' + tag.substr(1) +'?src=rph';
} else if(tag.startsWith('@') == true) {
tag = '/' + i.innerText + '?src=rpp';
} else {
tag = '/i/redirect?url=' + encodeURIComponent(tag);
}
return tag;
})
},
filters: [
['1984','filter-1977'],
['Azen','filter-aden'],
['Astairo','filter-amaro'],
['Grassbee','filter-ashby'],
['Bookrun','filter-brannan'],
['Borough','filter-brooklyn'],
['Farms','filter-charmes'],
['Hairsadone','filter-clarendon'],
['Cleana ','filter-crema'],
['Catpatch','filter-dogpatch'],
['Earlyworm','filter-earlybird'],
['Plaid','filter-gingham'],
['Kyo','filter-ginza'],
['Yefe','filter-hefe'],
['Goddess','filter-helena'],
['Yards','filter-hudson'],
['Quill','filter-inkwell'],
['Rankine','filter-kelvin'],
['Juno','filter-juno'],
['Mark','filter-lark'],
['Chill','filter-lofi'],
['Van','filter-ludwig'],
['Apache','filter-maven'],
['May','filter-mayfair'],
['Ceres','filter-moon'],
['Knoxville','filter-nashville'],
['Felicity','filter-perpetua'],
['Sandblast','filter-poprocket'],
['Daisy','filter-reyes'],
['Elevate','filter-rise'],
['Nevada','filter-sierra'],
['Futura','filter-skyline'],
['Sleepy','filter-slumber'],
['Steward','filter-stinson'],
['Savoy','filter-sutro'],
['Blaze','filter-toaster'],
['Apricot','filter-valencia'],
['Gloming','filter-vesper'],
['Walter','filter-walden'],
['Poplar','filter-willow'],
['Xenon','filter-xpro-ii']
],
filterCss: {
'filter-1977': 'sepia(.5) hue-rotate(-30deg) saturate(1.4)',
'filter-aden': 'sepia(.2) brightness(1.15) saturate(1.4)',
'filter-amaro': 'sepia(.35) contrast(1.1) brightness(1.2) saturate(1.3)',
'filter-ashby': 'sepia(.5) contrast(1.2) saturate(1.8)',
'filter-brannan': 'sepia(.4) contrast(1.25) brightness(1.1) saturate(.9) hue-rotate(-2deg)',
'filter-brooklyn': 'sepia(.25) contrast(1.25) brightness(1.25) hue-rotate(5deg)',
'filter-charmes': 'sepia(.25) contrast(1.25) brightness(1.25) saturate(1.35) hue-rotate(-5deg)',
'filter-clarendon': 'sepia(.15) contrast(1.25) brightness(1.25) hue-rotate(5deg)',
'filter-crema': 'sepia(.5) contrast(1.25) brightness(1.15) saturate(.9) hue-rotate(-2deg)',
'filter-dogpatch': 'sepia(.35) saturate(1.1) contrast(1.5)',
'filter-earlybird': 'sepia(.25) contrast(1.25) brightness(1.15) saturate(.9) hue-rotate(-5deg)',
'filter-gingham': 'contrast(1.1) brightness(1.1)',
'filter-ginza': 'sepia(.25) contrast(1.15) brightness(1.2) saturate(1.35) hue-rotate(-5deg)',
'filter-hefe': 'sepia(.4) contrast(1.5) brightness(1.2) saturate(1.4) hue-rotate(-10deg)',
'filter-helena': 'sepia(.5) contrast(1.05) brightness(1.05) saturate(1.35)',
'filter-hudson': 'sepia(.25) contrast(1.2) brightness(1.2) saturate(1.05) hue-rotate(-15deg)',
'filter-inkwell': 'brightness(1.25) contrast(.85) grayscale(1)',
'filter-kelvin': 'sepia(.15) contrast(1.5) brightness(1.1) hue-rotate(-10deg)',
'filter-juno': 'sepia(.35) contrast(1.15) brightness(1.15) saturate(1.8)',
'filter-lark': 'sepia(.25) contrast(1.2) brightness(1.3) saturate(1.25)',
'filter-lofi': 'saturate(1.1) contrast(1.5)',
'filter-ludwig': 'sepia(.25) contrast(1.05) brightness(1.05) saturate(2)',
'filter-maven': 'sepia(.35) contrast(1.05) brightness(1.05) saturate(1.75)',
'filter-mayfair': 'contrast(1.1) brightness(1.15) saturate(1.1)',
'filter-moon': 'brightness(1.4) contrast(.95) saturate(0) sepia(.35)',
'filter-nashville': 'sepia(.25) contrast(1.5) brightness(.9) hue-rotate(-15deg)',
'filter-perpetua': 'contrast(1.1) brightness(1.25) saturate(1.1)',
'filter-poprocket': 'sepia(.15) brightness(1.2)',
'filter-reyes': 'sepia(.75) contrast(.75) brightness(1.25) saturate(1.4)',
'filter-rise': 'sepia(.25) contrast(1.25) brightness(1.2) saturate(.9)',
'filter-sierra': 'sepia(.25) contrast(1.5) brightness(.9) hue-rotate(-15deg)',
'filter-skyline': 'sepia(.15) contrast(1.25) brightness(1.25) saturate(1.2)',
'filter-slumber': 'sepia(.35) contrast(1.25) saturate(1.25)',
'filter-stinson': 'sepia(.35) contrast(1.25) brightness(1.1) saturate(1.25)',
'filter-sutro': 'sepia(.4) contrast(1.2) brightness(.9) saturate(1.4) hue-rotate(-10deg)',
'filter-toaster': 'sepia(.25) contrast(1.5) brightness(.95) hue-rotate(-15deg)',
'filter-valencia': 'sepia(.25) contrast(1.1) brightness(1.1)',
'filter-vesper': 'sepia(.35) contrast(1.15) brightness(1.2) saturate(1.3)',
'filter-walden': 'sepia(.35) contrast(.8) brightness(1.25) saturate(1.4)',
'filter-willow': 'brightness(1.2) contrast(.85) saturate(.05) sepia(.2)',
'filter-xpro-ii': 'sepia(.45) contrast(1.25) brightness(1.75) saturate(1.3) hue-rotate(-5deg)'
},
emoji: ['😂','💯','❤️','🙌','👏','👌','😍','😯','😢','😅','😁','🙂','😎','😀','🤣','😃','😄','😆','😉','😊','😋','😘','😗','😙','😚','🤗','🤩','🤔','🤨','😐','😑','😶','🙄','😏','😣','😥','😮','🤐','😪','😫','😴','😌','😛','😜','😝','🤤','😒','😓','😔','😕','🙃','🤑','😲','🙁','😖','😞','😟','😤','😭','😦','😧','😨','😩','🤯','😬','😰','😱','😳','🤪','😵','😡','😠','🤬','😷','🤒','🤕','🤢','🤮','🤧','😇','🤠','🤡','🤥','🤫','🤭','🧐','🤓','😈','👿','👹','👺','💀','👻','👽','🤖','💩','😺','😸','😹','😻','😼','😽','🙀','😿','😾','🤲','👐','🤝','👍','👎','👊','✊','🤛','🤜','🤞','✌️','🤟','🤘','👈','👉','👆','👇','☝️','✋','🤚','🖐','🖖','👋','🤙','💪','🖕','✍️','🙏','💍','💄','💋','👄','👅','👂','👃','👣','👁','👀','🧠','🗣','👤','👥'
],
embed: {
post: (function(url, caption = true, likes = false, layout = 'full') {
let u = url + '/embed?';
u += caption ? 'caption=true&' : 'caption=false&';
u += likes ? 'likes=true&' : 'likes=false&';
u += layout == 'compact' ? 'layout=compact' : 'layout=full';
return '<iframe title="Pixelfed Post Embed" src="'+u+'" class="pixelfed__embed" style="max-width: 100%; border: 0" width="400" allowfullscreen="allowfullscreen"></iframe><script async defer src="'+window.location.origin +'/embed.js"><\/script>';
}),
profile: (function(url) {
let u = url + '/embed';
return '<iframe title="Pixelfed Profile Embed" src="'+u+'" class="pixelfed__embed" style="max-width: 100%; border: 0" width="400" allowfullscreen="allowfullscreen"></iframe><script async defer src="'+window.location.origin +'/embed.js"><\/script>';
})
},
clipboard: (function(data) {
return navigator.clipboard.writeText(data);
}),
navatar: (function() {
$('#navbarDropdown .far').addClass('d-none');
$('#navbarDropdown img').attr('src',window._sharedData.curUser.avatar)
.removeClass('d-none')
.addClass('rounded-circle border shadow')
.attr('width', 34).attr('height', 34);
})
};
const warningTitleCSS = 'color:red; font-size:60px; font-weight: bold; -webkit-text-stroke: 1px black;';
const warningDescCSS = 'font-size: 18px;';
console.log('%cStop!', warningTitleCSS);
console.log("%cThis is a browser feature intended for developers. If someone told you to copy and paste something here to enable a Pixelfed feature or \"hack\" someone's account, it is a scam and will give them access to your Pixelfed account.", warningDescCSS);

4
resources/assets/js/stories.js vendored Normal file
View file

@ -0,0 +1,4 @@
Vue.component(
'story-viewer',
require('./components/StoryViewer.vue').default
);

29
resources/assets/sass/lib/manrope.scss vendored Normal file
View file

@ -0,0 +1,29 @@
/* latin */
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url(/fonts/xn7gYHE41ni1AdIRggexSvfedN4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin */
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/xn7gYHE41ni1AdIRggexSvfedN4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin */
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/xn7gYHE41ni1AdIRggexSvfedN4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

63
webpack.mix.js vendored
View file

@ -1,8 +1,16 @@
let mix = require('laravel-mix');
const fs = require("fs");
mix.before(() => {
fs.rmSync('public/js', { recursive: true, force: true });
});
mix.sass('resources/assets/sass/app.scss', 'public/css')
.sass('resources/assets/sass/appdark.scss', 'public/css')
.sass('resources/assets/sass/admin.scss', 'public/css')
.sass('resources/assets/sass/portfolio.scss', 'public/css')
.sass('resources/assets/sass/spa.scss', 'public/css')
.sass('resources/assets/sass/landing.scss', 'public/css').version();
mix.js('resources/assets/js/app.js', 'public/js')
@ -16,24 +24,51 @@ mix.js('resources/assets/js/app.js', 'public/js')
.js('resources/assets/js/compose-classic.js', 'public/js')
.js('resources/assets/js/search.js', 'public/js')
.js('resources/assets/js/developers.js', 'public/js')
.js('resources/assets/js/loops.js', 'public/js')
.js('resources/assets/js/hashtag.js', 'public/js')
.js('resources/assets/js/collectioncompose.js', 'public/js')
.js('resources/assets/js/collections.js', 'public/js')
.js('resources/assets/js/profile-directory.js', 'public/js')
.js('resources/assets/js/story-compose.js', 'public/js')
.js('resources/assets/js/direct.js', 'public/js')
.js('resources/assets/js/direct.js', 'public/js')
.js('resources/assets/js/admin.js', 'public/js')
.js('resources/assets/js/rempro.js', 'public/js')
.js('resources/assets/js/rempos.js', 'public/js')
.js('resources/assets/js/spa.js', 'public/js')
.js('resources/assets/js/stories.js', 'public/js')
.js('resources/assets/js/portfolio.js', 'public/js')
.js('resources/assets/js/admin_invite.js', 'public/js')
.js('resources/assets/js/landing.js', 'public/js')
.vue({ version: 2 });
.extract([
'lodash',
'popper.js',
'jquery',
'axios',
'bootstrap',
'vue',
'readmore-js'
])
.version();
mix.extract();
mix.version();
const TerserPlugin = require('terser-webpack-plugin');
mix.options({
processCssUrls: false,
terser: {
parallel: true,
terserOptions: {
compress: true,
output: {
comments: false
}
}
}
})
mix.webpackConfig({
optimization: {
providedExports: false,
sideEffects: false,
usedExports: false,
minimize: true,
minimizer: [ new TerserPlugin({
extractComments: false,
})]
},
output: {
chunkFilename: 'js/[name].[chunkhash].js',
}
});
mix.autoload({
jquery: ['$', 'jQuery', 'window.jQuery']
});