Add Groups vues

This commit is contained in:
Daniel Supernault 2024-07-09 23:52:08 -06:00
parent 3d6b9badf4
commit 3811a1cd65
No known key found for this signature in database
GPG key ID: 23740873EE6F76A1
65 changed files with 15176 additions and 0 deletions

View file

@ -0,0 +1,51 @@
<template>
<div class="group-notifications-component">
<div class="row border-bottom m-0 p-0">
<sidebar />
<create-group />
</div>
</div>
</template>
<script type="text/javascript">
import SidebarComponent from '@/groups/sections/Sidebar.vue';
import LoaderComponent from '@/groups/sections/Loader.vue';
import CreateGroup from '@/groups/CreateGroup.vue';
export default {
components: {
"sidebar": SidebarComponent,
"loader": LoaderComponent,
"create-group": CreateGroup
},
data() {
return {
loaded: false,
loadTimeout: undefined,
}
},
created() {
this.loadTimeout = setTimeout(() => {
this.loaded = true;
}, 1000);
},
beforeUnmount() {
clearTimeout(this.loadTimeout);
}
}
</script>
<style lang="scss" scoped>
.group-notifications-component {
font-family: var(--font-family-sans-serif);
.jumbotron {
background-color: #fff;
border-radius: 0px;
}
}
</style>

View file

@ -0,0 +1,83 @@
<template>
<div class="group-discover-component">
<div class="row border-bottom m-0 p-0">
<sidebar />
<div class="col-12 col-md-9 px-md-0">
<loader v-if="!loaded" :loaded="loaded" />
<template v-else>
<div class="container-fluid">
<div class="py-5">
<h1>Discover</h1>
</div>
<div class="popular row">
<group-card
v-for="(popular, idx) in popularGroups"
:key="idx"
:group="popular"
/>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import SidebarComponent from '@/groups/sections/Sidebar.vue';
import LoaderComponent from '@/groups/sections/Loader.vue';
import GroupCard from '@/groups/partials/GroupCard.vue';
export default {
components: {
"sidebar": SidebarComponent,
"loader": LoaderComponent,
"group-card": GroupCard
},
data() {
return {
loaded: false,
loadTimeout: undefined,
popularGroups: [],
newGroups: [],
}
},
methods: {
fetchPopular() {
axios.get('/api/v0/groups/discover/popular')
.then(res => this.popularGroups = res.data)
.finally(() => this.fetchNewGroups())
},
fetchNewGroups() {
axios.get('/api/v0/groups/discover/new')
.then(res => this.newGroups = res.data)
.finally(() => this.loaded = true)
},
},
created() {
this.fetchPopular()
},
beforeUnmount() {
clearTimeout(this.loadTimeout);
}
}
</script>
<style lang="scss" scoped>
.group-discover-component {
font-family: var(--font-family-sans-serif);
.jumbotron {
background-color: #fff;
border-radius: 0px;
}
}
</style>

View file

@ -0,0 +1,315 @@
<template>
<div class="groups-home-component w-100 h-100">
<div v-if="initialLoad" class="row border-bottom m-0 p-0">
<sidebar />
<self-feed :profile="profile" v-on:switchtab="switchTab" />
<!-- <self-discover v-if="tab == 'discover'" :profile="profile" />
<self-notifications v-if="tab == 'notifications'" :profile="profile" />
<self-invitations v-if="tab == 'invitations'" :profile="profile" />
<self-remote-search v-if="tab == 'remotesearch'" :profile="profile" />
<self-groups v-if="tab == 'mygroups'" :profile="profile" />
<create-group v-if="tab == 'creategroup'" :profile="profile" />
<div v-if="tab == 'gsearch'">
<div class="col-12 px-5">
<div class="my-4">
<p class="h1 font-weight-bold mb-1">Group Search</p>
<p class="lead text-muted mb-0">Search and explore groups.</p>
</div>
<div class="media align-items-center text-lighter">
<i class="far fa-chevron-left fa-lg mr-3"></i>
<div class="media-body">
<p class="lead mb-0">Use the search bar on the side menu</p>
</div>
</div>
</div>
</div> -->
</div>
<div v-else class="row justify-content-center mt-5">
<b-spinner />
</div>
</div>
</template>
<script type="text/javascript">
import GroupStatus from '@/groups/partials/GroupStatus.vue';
import SelfFeed from '@/groups/partials/SelfFeed.vue';
import SelfDiscover from '@/groups/partials/SelfDiscover.vue';
import SelfGroups from '@/groups/partials/SelfGroups.vue';
import SelfNotifications from '@/groups/partials/SelfNotifications.vue';
import SelfInvitations from '@/groups/partials/SelfInvitations.vue';
import SelfRemoteSearch from '@/groups/partials/SelfRemoteSearch.vue';
import CreateGroup from '@/groups/CreateGroup.vue';
import SidebarComponent from '@/groups/sections/Sidebar.vue';
import Autocomplete from '@trevoreyre/autocomplete-vue'
import '@trevoreyre/autocomplete-vue/dist/style.css'
export default {
data() {
return {
initialLoad: false,
config: {},
groups: [],
profile: {},
tab: null,
searchQuery: undefined,
};
},
components: {
'autocomplete-input': Autocomplete,
'group-status': GroupStatus,
'self-discover': SelfDiscover,
'self-groups': SelfGroups,
'self-feed': SelfFeed,
'self-notifications': SelfNotifications,
'self-invitations': SelfInvitations,
'self-remote-search': SelfRemoteSearch,
"create-group": CreateGroup,
"sidebar": SidebarComponent
},
mounted() {
this.fetchConfig();
},
methods: {
init() {
document.querySelectorAll("footer").forEach(e => e.parentNode.removeChild(e));
document.querySelectorAll(".mobile-footer-spacer").forEach(e => e.parentNode.removeChild(e));
document.querySelectorAll(".mobile-footer").forEach(e => e.parentNode.removeChild(e));
// let u = new URLSearchParams(window.location.search);
// if(u.has('ct')) {
// if(['mygroups', 'notifications', 'discover', 'remotesearch', 'creategroup', 'gsearch'].includes(u.get('ct'))) {
// if(u.get('ct') == 'creategroup' && this.config.limits.user.create.new == false) {
// this.tab = 'feed';
// history.pushState(null, null, '/groups/feed');
// } else {
// this.tab = u.get('ct');
// }
// } else {
// this.tab = 'feed';
// history.pushState(null, null, '/groups/feed');
// }
// } else {
// this.tab = 'feed';
// }
this.initialLoad = true;
},
fetchConfig() {
axios.get('/api/v0/groups/config')
.then(res => {
this.config = res.data;
this.fetchProfile();
});
},
fetchProfile() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.profile = res.data;
this.init();
window._sharedData.curUser = res.data;
window.App.util.navatar();
})
},
fetchSelfGroups() {
axios.get('/api/v0/groups/self/list')
.then(res => {
this.groups = res.data;
});
},
switchTab(tab) {
event.currentTarget.blur();
window.scrollTo(0,0);
this.tab = tab;
if(tab != 'feed') {
history.pushState(null, null, '/groups/home?ct=' + tab);
} else {
history.pushState(null, null, '/groups/home');
}
},
autocompleteSearch(input) {
if (!input || input.length < 2) {
if(this.tab = 'searchresults') {
this.tab = 'feed';
}
return [];
};
this.searchQuery = input;
// this.tab = 'searchresults';
if(input.startsWith('http')) {
let url = new URL(input);
if(url.hostname == location.hostname) {
location.href = input;
return [];
}
return [];
}
if(input.startsWith('#')) {
this.$bvToast.toast(input, {
title: 'Hashtag detected',
variant: 'info',
autoHideDelay: 5000
});
return [];
}
return axios.post('/api/v0/groups/search/global', {
q: input,
v: '0.2'
})
.then(res => {
this.searchLoading = false;
return res.data;
}).catch(err => {
if(err.response.status === 422) {
this.$bvToast.toast(err.response.data.error.message, {
title: 'Cannot display search results',
variant: 'danger',
autoHideDelay: 5000
});
}
return [];
})
},
getSearchResultValue(result) {
return result.name;
},
onSearchSubmit(result) {
if (result.length < 1) {
return [];
}
location.href = result.url;
},
truncateName(val) {
if(val.length < 24) {
return val;
}
return val.substr(0, 23) + '...';
}
}
}
</script>
<style lang="scss">
.groups-home-component {
font-family: var(--font-family-sans-serif);
.group-nav-btn {
display: block;
width: 100%;
padding-left: 0;
padding-top: 0.3rem;
padding-bottom: 0.3rem;
margin-bottom: 0.3rem;
border-radius: 1.5rem;
text-align: left;
color: #6c757d;
background-color: transparent;
border-color: transparent;
justify-content: flex-start;
&.active {
background-color: #EFF6FF !important;
border:1px solid #DBEAFE !important;
color: #212529;
.group-nav-btn-icon {
background-color: #2c78bf !important;
color: #fff !important;
}
}
&-icon {
display: inline-flex;
width: 35px;
height: 35px;
padding: 12px;
background-color: #E5E7EB;
border-radius: 17px;
margin: auto 0.3rem;
align-items: center;
justify-content: center;
}
&-name {
display: inline-block;
margin-left: 0.3rem;
font-weight: 700;
}
}
.autocomplete-input {
height: 2.375rem;
background-color: #f8f9fa !important;
font-size: 0.9rem;
color: #495057;
border-radius: 50rem;
border-color: transparent;
&:focus,
&[aria-expanded=true] {
box-shadow: none;
}
}
.autocomplete-result {
background: none;
padding: 12px;
&:hover,
&:focus {
background-color: #EFF6FF !important;
}
.media {
img {
object-fit: cover;
border-radius: 4px;
margin-right: 0.6rem;
}
.icon-placeholder {
display: flex;
width: 32px;
height: 32px;
background-color: #2c78bf;
border-radius: 4px;
justify-content: center;
align-items: center;
color: #fff;
margin-right: 0.6rem;
}
}
}
.autocomplete-result-list {
padding-bottom: 0;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 200ms;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
}
</style>

View file

@ -0,0 +1,79 @@
<template>
<div class="group-joins-component">
<div class="row border-bottom m-0 p-0">
<sidebar />
<div class="col-12 col-md-9 px-0 mx-0">
<loader v-if="!loaded" :loaded="loaded" />
<template v-else>
<div class="px-5 pt-4 pb-2">
<h2 class="fw-bold">My Groups</h2>
<self-groups :profile="profile" />
</div>
</template>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import SidebarComponent from '@/groups/sections/Sidebar.vue';
import LoaderComponent from '@/groups/sections/Loader.vue';
import SelfGroups from '@/groups/partials/SelfGroups.vue';
export default {
components: {
"sidebar": SidebarComponent,
"loader": LoaderComponent,
"self-groups": SelfGroups
},
data() {
return {
loaded: false,
loadTimeout: undefined,
config: {},
groups: [],
profile: {},
}
},
methods: {
init() {
document.querySelectorAll("footer").forEach(e => e.parentNode.removeChild(e));
document.querySelectorAll(".mobile-footer-spacer").forEach(e => e.parentNode.removeChild(e));
document.querySelectorAll(".mobile-footer").forEach(e => e.parentNode.removeChild(e));
this.loaded = true;
},
fetchConfig() {
axios.get('/api/v0/groups/config')
.then(res => {
this.config = res.data;
this.fetchProfile();
});
},
fetchProfile() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.profile = res.data;
this.init();
window._sharedData.curUser = res.data;
window.App.util.navatar();
})
},
},
created() {
this.fetchConfig();
}
}
</script>
<style lang="scss" scoped>
.group-joins-component {
font-family: var(--font-family-sans-serif);
}
</style>

View file

@ -0,0 +1,57 @@
<template>
<div class="group-notifications-component">
<div class="row border-bottom m-0 p-0">
<sidebar />
<div class="col-12 col-md-9 px-0 mx-0">
<loader v-if="!loaded" :loaded="loaded" />
<template v-else>
<div class="px-5 pt-4 pb-2">
<h2 class="fw-bold">Group Notifications</h2>
</div>
</template>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import SidebarComponent from '@/groups/sections/Sidebar.vue';
import LoaderComponent from '@/groups/sections/Loader.vue';
export default {
components: {
"sidebar": SidebarComponent,
"loader": LoaderComponent
},
data() {
return {
loaded: false,
loadTimeout: undefined,
}
},
created() {
this.loadTimeout = setTimeout(() => {
this.loaded = true;
}, 1000);
},
beforeUnmount() {
clearTimeout(this.loadTimeout);
}
}
</script>
<style lang="scss" scoped>
.group-notifications-component {
font-family: var(--font-family-sans-serif);
.jumbotron {
background-color: #fff;
border-radius: 0px;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
<template>
<div class="group-status-permalink-component">
<div class="row border-bottom m-0 p-0">
<sidebar />
<div class="col-12 col-md-9 px-md-0">
<group-feed :group-id="gid" :permalinkMode="true" :permalinkId="sid" />
</div>
</div>
</div>
</template>
<script type="text/javascript">
import GroupFeed from '@/groups/GroupFeed.vue';
import SidebarComponent from '@/groups/sections/Sidebar.vue';
export default {
props: {
gid: {
type: String
},
sid: {
type: String
}
},
components: {
"group-feed": GroupFeed,
"sidebar": SidebarComponent
}
}
</script>

View file

@ -0,0 +1,443 @@
<template>
<div class="group-profile-component w-100 h-100">
<div v-if="!loaded" class="w-100 h-100">
<div class="d-flex justify-content-center align-items-center mt-5">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
<template v-else>
<div class="bg-white mb-3 border-bottom">
<div class="container-xl header">
<div class="header-jumbotron"></div>
<div class="header-profile-card">
<img :src="profile.avatar" class="avatar" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
<p class="name">
{{ profile.display_name }}
</p>
<p class="username text-muted">
<span v-if="profile.local">&commat;{{ profile.username }}</span>
<span v-else>{{ profile.acct }}</span>
<span v-if="profile.is_admin" class="text-danger ml-1" title="Site administrator" data-toggle="tooltip" data-placement="bottom"><i class="far fa-users-crown"></i></span>
</p>
</div>
<!-- <hr> -->
<div class="header-navbar">
<div></div>
<div>
<a
v-if="currentProfile.id === profile.id"
class="btn btn-light font-weight-bold mr-2"
href="/settings/home">
<i class="fas fa-edit mr-1"></i> Edit Profile
</a>
<a
v-if="relationship.following"
class="btn btn-primary font-weight-bold mr-2"
:href="profile.url">
<i class="far fa-comment-alt-dots mr-1"></i> Message
</a>
<a
v-if="relationship.following"
class="btn btn-light font-weight-bold mr-2"
:href="profile.url">
<i class="fas fa-user-check mr-1"></i> {{ relationship.followed_by ? 'Friends' : 'Following' }}
</a>
<a
v-if="!relationship.following"
class="btn btn-light font-weight-bold mr-2"
:href="profile.url">
<i class="fas fa-user mr-1"></i> View Main Profile
</a>
<div class="dropdown">
<button class="btn btn-light font-weight-bold dropdown-toggle" type="button" id="amenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-h"></i>
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="amenu">
<a v-if="currentProfile.id != profile.id" class="dropdown-item font-weight-bold" :href="`/i/report?type=user&id=${profile.id}`">Report</a>
<a v-if="currentProfile.id == profile.id" class="dropdown-item font-weight-bold" href="#">Leave Group</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="w-100 h-100 group-profile-feed">
<div class="container-xl">
<div class="row">
<div class="col-12 col-md-5">
<div class="card card-body shadow-sm infolet">
<h5 class="font-weight-bold mb-3">Intro</h5>
<div v-if="!profile.local" class="media mb-3 align-items-center">
<div class="media-icon">
<i class="far fa-globe" title="User is from a remote server" data-toggle="tooltip" data-placement="bottom"></i>
</div>
<div class="media-body">
Remote member from <strong>{{ profile.acct.split('@')[1] }}</strong>
</div>
</div>
<!-- <div v-if="profile.note.length" class="media mb-3 align-items-center">
<i class="far fa-book-user fa-lg text-lighter mr-3" title="User bio" data-toggle="tooltip" data-placement="bottom"></i>
<div class="media-body">
<span v-html="profile.note"></span>
</div>
</div> -->
<div class="media align-items-center">
<div class="media-icon">
<i class="fas fa-users" title="User joined group on this date" data-toggle="tooltip" data-placement="bottom"></i>
</div>
<div class="media-body">
{{ roleTitle }} of <strong>{{ group.name }}</strong> since {{ formatJoinedDate(profile.group?.joined) }}
</div>
</div>
</div>
<div v-if="canIntersect" class="card card-body shadow-sm infolet">
<h5 class="font-weight-bold mb-3">Things in Common</h5>
<div v-if="commonIntersects.friends.length" class="media mb-3 align-items-center" v-once>
<div class="media-icon">
<i class="far fa-user-friends"></i>
</div>
<div class="media-body">
{{ commonIntersects.friends_count }} mutual friend<span v-if="commonIntersects.friends.length > 1">s</span> including
<span v-for="(friend, index) in commonIntersects.friends"><a :href="friend.url" class="text-dark font-weight-bold">{{ friend.acct }}</a><span v-if="commonIntersects.friends.length > index + 1">, </span><span v-else> </span></span>
<!-- <a href="#" class="text-dark font-weight-bold">dansup</a>, <a href="#" class="text-dark font-weight-bold">admin</a> and 1 other -->
</div>
</div>
<!-- <div class="media mb-3 align-items-center">
<div class="media-icon">
<i class="fas fa-home"></i>
</div>
<div class="media-body">
Lives in <strong>Canada</strong>
</div>
</div> -->
<div v-if="commonIntersects.groups.length" class="media mb-3 align-items-center">
<div class="media-icon">
<i class="fas fa-users"></i>
</div>
<div class="media-body">
Also member of <a :href="commonIntersects.groups[0].url" class="text-dark font-weight-bold">{{ commonIntersects.groups[0].name }}</a> and {{ commonIntersects.groups_count }} other groups
</div>
</div>
<div v-if="commonIntersects.topics.length" class="media mb-0 align-items-center">
<div class="media-icon">
<i class="far fa-thumbs-up fa-lg text-lighter"></i>
</div>
<div class="media-body">
Also interested in topics containing
<span v-for="(topic, index) in commonIntersects.topics">
<span v-if="commonIntersects.topics.length - 1 == index"> and </span><a :href="topic.url" class="font-weight-bold text-dark">#{{ topic.name }}</a><span v-if="commonIntersects.topics.length > index + 2">, </span>
</span> hashtags
</div>
</div>
</div>
</div>
<div class="col-12 col-md-7">
<div class="card card-body shadow-sm">
<h5 class="font-weight-bold mb-0">Group Posts</h5>
</div>
<div v-if="feedEmpty" class="pt-5 text-center">
<h5>No New Posts</h5>
<p>{{ profile.username }} hasn't posted anything yet in <strong>{{ group.name }}</strong>.</p>
<a :href="group.url" class="font-weight-bold">Go Back</a>
</div>
<div v-if="feedLoaded" class="mt-2">
<group-status
v-for="(status, index) in feed"
:key="'gps:' + status.id"
:permalinkMode="true"
:showGroupChevron="true"
:group="group"
:prestatus="status"
:profile="profile"
:group-id="group.id" />
<div v-if="feed.length >= 1" :distance="800">
<infinite-loading @infinite="infiniteFeed">
<div slot="no-more"></div>
<div slot="no-results"></div>
</infinite-loading>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script type="text/javascript">
import GroupStatus from '@/groups/partials/GroupStatus.vue';
export default {
props: {
gid: {
type: String
},
pid: {
type: String
}
},
components: {
'group-status': GroupStatus
},
data() {
return {
loaded: false,
currentProfile: {},
roleTitle: 'Member',
group: {},
profile: {},
relationship: {
following: false,
},
feed: [],
ids: [],
feedLoaded: false,
feedEmpty: false,
page: 1,
canIntersect: false,
commonIntersects: []
}
},
beforeMount() {
$('body').css('background-color', '#f0f2f5');
},
mounted() {
this.fetchGroup();
this.$nextTick(() => {
$('[data-toggle="tooltip"]').tooltip();
});
},
methods: {
fetchGroup() {
axios.get('/api/v0/groups/' + this.gid)
.then(res => {
this.group = res.data;
})
.finally(() => {
this.fetchSelfProfile();
})
},
fetchSelfProfile() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.currentProfile = res.data;
})
.catch(err => {
this.$router.push('/groups/' + this.gid)
})
.finally(() => {
this.fetchProfile();
})
this.$nextTick(() => {
$('[data-toggle="tooltip"]').tooltip();
});
},
fetchProfile() {
axios.get('/api/v0/groups/accounts/' + this.gid + '/' + this.pid)
.then(res => {
this.profile = res.data;
if(res.data.group.role == 'founder') {
this.roleTitle = 'Admin';
}
})
if(window._sharedData.user.id == this.pid) {
this.fetchInitialFeed();
return;
}
axios.get('/api/v1/accounts/relationships?id[]=' + this.pid)
.then(res => {
this.relationship = res.data[0];
})
.finally(() => {
this.fetchInitialFeed();
})
},
fetchInitialFeed() {
if(window._sharedData.user && window._sharedData.user.id != this.pid) {
this.fetchCommonIntersections();
}
axios.get(`/api/v0/groups/${this.gid}/user/${this.pid}/feed`)
.then(res => {
this.feed = res.data.filter(s => {
return s.pf_type != 'reply:text' || s.account.id != this.profile.id;
});
this.feedLoaded = true;
this.feedEmpty = this.feed.length == 0;
this.page++;
this.loaded = true;
})
.catch(err => {
this.$router.push('/groups/' + this.gid);
console.log(err)
});
},
infiniteFeed($state) {
if(this.feed.length == 0) {
$state.complete();
return;
}
axios.get(`/api/v0/groups/${this.group.id}/user/${this.profile.id}/feed`, {
params: {
page: this.page
},
}).then(res => {
if (res.data.length) {
let data = res.data.filter(s => {
return s.pf_type != 'reply:text' || s.account.id != this.profile.id;
});
let self = this;
data.forEach(d => {
if(self.ids.indexOf(d.id) == -1) {
self.ids.push(d.id);
self.feed.push(d);
}
});
$state.loaded();
this.page++;
} else {
$state.complete();
}
});
},
fetchCommonIntersections() {
axios.get('/api/v0/groups/member/intersect/common', {
params: {
gid: this.gid,
pid: this.pid
}
}).then(res => {
this.commonIntersects = res.data;
this.canIntersect = res.data.groups.length || res.data.topics.length;
});
},
formatJoinedDate(ts) {
let date = new Date(ts);
let options = { year: 'numeric', month: 'long' };
let formatter = new Intl.DateTimeFormat('en-US', options);
return formatter.format(date);
}
}
}
</script>
<style lang="scss">
.group-profile-component {
background-color: #f0f2f5;
.header {
&-jumbotron {
background-color: #F3F4F6;
height: 320px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
&-profile-card {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.avatar {
width: 170px;
height: 170px;
border-radius: 50%;
margin-top: -150px;
margin-bottom: 20px;
}
.name {
font-size: 30px;
line-height: 30px;
font-weight: 700;
text-align: center;
margin-bottom: 6px;
}
.username {
font-size: 16px;
font-weight: 500;
text-align: center;
}
}
&-navbar {
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
border-top: 1px solid #F3F4F6;
.dropdown {
display: inline-block;
&-toggle:after {
display: none;
}
}
}
}
.group-profile-feed {
min-height: 500px;
}
.infolet {
margin-bottom: 1rem;
.media {
&-icon {
display: flex;
justify-content: center;
width: 30px;
margin-right: 10px;
i {
font-size: 1.1rem;
color: #D1D5DB !important;
}
}
}
}
.btn-light {
border-color: #F3F4F6;
}
}
</style>

View file

@ -0,0 +1,80 @@
<template>
<div class="group-component">
<div v-if="tab === 'home'">
<groups-home />
</div>
<div v-if="tab === 'createGroup'">
<!-- <div class="col-12 group-component-hero">
<h3 class="font-weight-bold">Create Group</h3>
<button class="btn btn-outline-primary px-3 rounded-pill font-weight-bold" @click="switchTab('home')">Back to Groups</button>
</div> -->
<create-group />
</div>
<div v-if="tab === 'show'">
<group-feed :group-id="groupId" :path="path" />
</div>
</div>
</template>
<script type="text/javascript">
import GroupsHome from '@/groups/GroupsHome.vue';
import GroupFeed from '@/groups/GroupFeed.vue';
import CreateGroup from '@/groups/CreateGroup.vue';
export default {
props: {
groupId: {
type: String
},
path: {
type: String
}
},
data() {
return {
tab: 'home'
}
},
components: {
"groups-home": GroupsHome,
"create-group": CreateGroup,
"group-feed": GroupFeed,
},
mounted() {
if(this.groupId) {
this.tab = 'show';
}
},
methods: {
switchTab(newTab) {
this.tab = newTab;
}
}
}
</script>
<style lang="scss">
.group-component {
&-hero {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #dee2e6;
border-top: 0;
background-color: #fff;
h3 {
margin-bottom: 0;
}
}
}
</style>

View file

@ -0,0 +1,359 @@
<template>
<div class="create-group-component col-12 col-md-9" style="height: 100vh - 51px !important;overflow:hidden">
<div v-if="!hide" class="row h-100 bg-lighter">
<div class="col-12 col-md-8 border-left">
<div class="bg-dark p-5 mx-n3">
<p class="h1 font-weight-bold text-light mb-2">Create Group</p>
<p class="text-lighter mb-0">Create a new federated Group that is compatible with other Pixelfed and Lemmy servers</p>
</div>
<div class="px-2 mb-5">
<div class="mt-4">
<text-input
label="Group Name"
:value="name"
:hasLimit="true"
:maxLimit="limit.name.max"
placeholder="Add your group name"
helpText="Alphanumeric characters only, you can change this later."
:largeInput="true"
@update="handleUpdate('name', $event)"
/>
<hr>
<select-input
label="Group Type"
:value="membership"
:categories="membershipCategories"
placeholder="Select a type"
helpText="Select the membership type, you can change this later."
@update="handleUpdate('membership', $event)"
/>
<hr>
<select-input
label="Group Category"
:value="category"
:categories="categories"
placeholder="Select a category"
helpText="Choose the most relevant category to improve discovery and visibility"
@update="handleUpdate('category', $event)"
/>
<hr>
<text-area-input
label="Group Description"
:value="description"
:hasLimit="true"
:maxLimit="limit.description.max"
placeholder="Describe your groups purpose in a few words"
helpText="Describe your groups purpose in a few words, you can change this later."
@update="handleUpdate('description', $event)"
/>
<hr>
<checkbox-input
label="Adult Content"
inputText="Allow Adult Content"
:value="configuration.adult"
helpText="Groups that allow adult content should enable this or risk suspension or deletion by instance admins. Illegal content is prohibited. You can change this later."
/>
<hr>
<checkbox-input
label=""
inputText="I agree to the the Community Guidelines and Terms of Use and will administrate this group according to the rules set by this server. I understand that failure to abide by these terms may lead to the suspension of this group, and my account."
:value="hasConfirmed"
:strongText="false"
@update="handleUpdate('hasConfirmed', $event)"
/>
<!-- <div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="gridCheck1" v-model="hasConfirmed">
<label class="form-check-label" for="gridCheck1">
I agree to the <a href="#">Community Guidelines</a> and <a href="#">Terms of Use</a>
</label>
</div>
</div>
</div> -->
<button
class="btn btn-primary btn-block font-weight-bold rounded-pill mt-4"
@click="createGroup"
:disabled="!hasConfirmed">
Create Group
</button>
</div>
</div>
</div>
<div class="col-12 col-md-4 bg-white">
<!-- <div>
<button
v-if="page <= 4"
class="btn btn-primary btn-block font-weight-bold rounded-pill mt-4"
@click="nextPage"
:disabled="!membership">
Next
</button>
<button
v-if="page == 5"
class="btn btn-primary btn-block font-weight-bold rounded-pill mt-4"
@click="createGroup"
:disabled="!hasConfirmed">
Create Group
</button>
<button
v-if="page >= 2"
class="btn btn-light btn-block font-weight-bold rounded-pill border mt-4"
@click="prevPage">
Back
</button>
<div v-if="maxPage > 2" class="mt-4">
<p v-if="name && name.length">
<span class="text-lighter">Name:</span>
<span class="font-weight-bold">{{ name }}</span>
</p>
<p v-if="description && description.length">
<span class="text-lighter">Description:</span>
<span>{{ description }}</span>
</p>
<p v-if="membership && membership.length">
<span class="text-lighter">Membership:</span>
<span class="text-capitalize">{{ membership }}</span>
</p>
<p v-if="category && category.length">
<span class="text-lighter">Category:</span>
<span class="text-capitalize">{{ category }}</span>
</p>
</div>
</div> -->
</div>
</div>
</div>
</template>
<script type="text/javascript">
import TextInput from '@/groups/partials/CreateForm/TextInput.vue';
import SelectInput from '@/groups/partials/CreateForm/SelectInput.vue';
import TextAreaInput from '@/groups/partials/CreateForm/TextAreaInput.vue';
import CheckboxInput from '@/groups/partials/CreateForm/CheckboxInput.vue';
export default {
components: {
"text-input": TextInput,
"select-input": SelectInput,
"text-area-input": TextAreaInput,
"checkbox-input": CheckboxInput,
},
data() {
return {
hide: true,
name: null,
page: 1,
maxPage: 1,
description: null,
membership: "placeholder",
submitting: false,
categories: [],
category: "",
limit: {
name: {
max: 60
},
description: {
max: 500
}
},
configuration: {
types: {
text: true,
photos: true,
videos: true,
// events: false,
polls: true
},
federation: true,
adult: false,
discoverable: false,
autospam: false,
dms: false,
slowjoin: {
enabled: false,
age: 90,
limit: {
post: 1,
comment: 20,
threads: 2,
likes: 5,
hashtags: 5,
mentions: 1,
autolinks: 1
}
}
},
hasConfirmed: false,
permissionChecked: false,
membershipCategories: [
{ key: 'Public', value: 'public' },
{ key: 'Private', value: 'private' },
{ key: 'Local', value: 'local' },
],
}
},
mounted() {
this.permissionCheck();
this.fetchCategories();
},
methods: {
permissionCheck() {
axios.post('/api/v0/groups/permission/create')
.then(res => {
if(res.data.permission == false) {
swal('Limit reached', 'You cannot create any more groups', 'error');
this.hide = true;
} else {
this.hide = false;
}
this.permissionChecked = true;
});
},
submit($event) {
$event.preventDefault();
this.submitting = true;
axios.post('/api/v0/groups/create', {
name: this.name,
description: this.description,
membership: this.membership
}).then(res => {
console.log(res.data);
window.location.href = res.data.url;
}).catch(err => {
console.log(err.response);
})
},
fetchCategories() {
axios.get('/api/v0/groups/categories/list')
.then(res => {
this.categories = res.data.map(c => {
return {
key: c,
value: c
}
});
})
},
createGroup() {
axios.post('/api/v0/groups/create', {
name: this.name,
description: this.description,
membership: this.membership,
configuration: this.configuration
})
.then(res => {
console.log(res.data);
location.href = res.data.url;
})
},
handleUpdate(key, val) {
this[key] = val;
}
}
}
</script>
<style lang="scss">
.create-group-component {
.submit-button {
width: 130px;
}
.multistep {
margin-top: 30px;
margin-bottom: 30px;
overflow: hidden;
counter-reset: step;
text-align: center;
padding-left: 0;
}
.multistep li {
list-style-type: none;
text-transform: uppercase;
font-size: 9px;
font-weight: 700;
width: 20%;
float: left;
position: relative;
color: #B8C2CC;
}
.multistep li.active {
color: #000;
}
.multistep li:before {
content: counter(step);
counter-increment: step;
width: 24px;
height: 24px;
line-height: 26px;
display: block;
font-size: 12px;
color: #B8C2CC;
background: #F3F4F6;
border-radius: 25px;
margin: 0 auto 10px auto;
transition: background 400ms;
}
.multistep li:after {
content: '';
width: 100%;
height: 2px;
background: #dee2e6;
position: absolute;
left: -50%;
top: 11px;
z-index: -1;
transition: background 400ms;
}
.multistep li:first-child:after {
content: none;
}
.multistep li.active:before,
.multistep li.active:after {
background: #2c78bf;
color: white;
transition: background 400ms;
}
.col-form-label {
font-weight: 600;
text-align: right;
}
}
</style>

View file

@ -0,0 +1,989 @@
<template>
<div class="group-feed-component">
<div v-if="!initalLoad">
<p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
</div>
<div v-else>
<div class="mb-3 border-bottom">
<div class="container-xl">
<group-banner
:group="group"
/>
<group-header-details
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
@refresh="handleRefresh"
/>
<group-nav-tabs
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
:atabs="atabs"
/>
</div>
</div>
<div class="container-xl group-feed-component-body">
<div class="row mb-5">
<div class="col-12 col-md-7 mt-3">
<div v-if="group.self.is_member">
<group-compose
v-if="initalLoad"
:profile="profile"
:group-id="groupId"
v-on:new-status="pushNewStatus" />
<div v-if="feed.length == 0" class="mt-3">
<div class="card card-body shadow-none border d-flex align-items-center justify-content-center" style="height: 200px;">
<p class="font-weight-bold mb-0">No posts yet!</p>
</div>
</div>
<div v-else class="group-timeline">
<p class="font-weight-bold mb-1">Recent Posts</p>
<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
<div class="card shadow-none border rounded-0">
<div class="card-body pb-0">
<div class="media">
<img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
<div class="media-body">
<div class="pl-2 d-flex align-items-top">
<div>
<p class="mb-0">
<a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
dansup
</a>
</p>
<p class="mb-0">
<a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
<span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
<span class="text-muted small">
<i class="fas fa-globe"></i>
</span>
</p>
</div>
<span class="text-right" style="flex-grow: 1;">
<button type="button" class="btn btn-link text-dark py-0">
<span class="fas fa-ellipsis-h text-lighter"></span>
<span class="sr-only">Post Menu</span>
</button>
</span>
</div>
</div>
</div>
<div>
<div>
<div class="">
<p class="pt-2 text-break" style="font-size: 15px;">
Made some improvements!
</p>
<div class="my-3 row px-0 mx-0 card card-body my-0 py-0 border shadow-none">
<img src="https://opengraph.githubassets.com/f66d0f7bf17df4a45382b83c1ffde2f25e3d700f9d87ab8c9ec2029c3a1e16b6/pixelfed/pixelfed/pull/2865" class="img-fluid">
<div class="bg-light px-3 pt-2 pb-3">
<p class="text-muted mb-0 small">GITHUB.COM</p>
<p class="mb-0" style="font-size: 16px;font-weight:500;">Update LikeController, add UndoLikePipeline and federate Undo Like ac by dansup · Pull Request #2865 · pixelfed/pixelfed</p>
<p class="mb-0 text-muted" style="font-size:14px;line-height:15px;">tivities</p>
</div>
</div>
<div class="border-top my-0">
<div class="d-flex justify-content-between py-2 px-4">
<button class="btn btn-link py-0 text-decoration-none text-muted">
<i class="far fa-heart mr-1"></i>
Like
</button>
<div>
<i class="far fa-comment cursor-pointer text-muted mr-1"></i>
Comment
</div>
<div>
<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
Share
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> -->
<!-- OGP -->
<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
<div class="card shadow-none border rounded-0">
<div class="card-body pb-0">
<div class="media">
<img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
<div class="media-body">
<div class="pl-2 d-flex align-items-top">
<div>
<p class="mb-0">
<a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
dansup
</a>
</p>
<p class="mb-0">
<a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
<span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
<span class="text-muted small">
<i class="fas fa-globe"></i>
</span>
</p>
</div>
<span class="text-right" style="flex-grow: 1;">
<button type="button" class="btn btn-link text-dark py-0">
<span class="fas fa-ellipsis-h text-lighter"></span>
<span class="sr-only">Post Menu</span>
</button>
</span>
</div>
</div>
</div>
<div>
<div>
<div class="">
<div class="my-3 row px-0 mx-0 card card-body my-0 py-0 border shadow-none">
<img src="https://www.ctvnews.ca/polopoly_fs/1.5533318.1628281952!/httpImage/image.jpg_gen/derivatives/landscape_620/image.jpg" class="img-fluid">
<div class="bg-light px-3 pt-2 pb-3">
<p class="text-muted mb-0 small">CTVNEWS.CA</p>
<p class="mb-0" style="font-size: 16px;font-weight:500;">No charges against Alberta man who fatally shot home intruder: RCMP</p>
<p class="mb-0 text-muted" style="font-size:14px;line-height:15px;">No charges will be laid against an Alberta man who shot and killed an intruder after being beaten with a baseball bat, RCMP announced Friday.</p>
</div>
</div>
<div class="border-top my-0">
<div class="d-flex justify-content-between py-2 px-4">
<button class="btn btn-link py-0 text-decoration-none text-muted">
<i class="far fa-heart mr-1"></i>
Like
</button>
<div>
<i class="far fa-comment cursor-pointer text-muted mr-1"></i>
Comment
</div>
<div>
<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
Share
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> -->
<!-- SOUNDCLOUD -->
<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
<div class="card shadow-none border rounded-0">
<div class="card-body pb-0">
<div class="media">
<img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
<div class="media-body">
<div class="pl-2 d-flex align-items-top">
<div>
<p class="mb-0">
<a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
dansup
</a>
</p>
<p class="mb-0">
<a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
<span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
<span class="text-muted small">
<i class="fas fa-globe"></i>
</span>
</p>
</div>
<span class="text-right" style="flex-grow: 1;">
<button type="button" class="btn btn-link text-dark py-0">
<span class="fas fa-ellipsis-h text-lighter"></span>
<span class="sr-only">Post Menu</span>
</button>
</span>
</div>
</div>
</div>
<div>
<div>
<div class="">
<p class="pt-2 text-break" style="font-size: 15px;">
What does everyone think??
</p>
<div class="my-3 row p-0 m-0">
<iframe width="100%" height="166" scrolling="no" frameborder="no" allow="autoplay" src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/34019569&color=0066cc"></iframe><div style="font-size: 10px; color: #cccccc;line-break: anywhere;word-break: normal;overflow: hidden;white-space: nowrap;text-overflow: ellipsis; font-family: Interstate,Lucida Grande,Lucida Sans Unicode,Lucida Sans,Garuda,Verdana,Tahoma,sans-serif;font-weight: 100;"><a href="https://soundcloud.com/the-bugle" title="The Bugle" target="_blank" style="color: #cccccc; text-decoration: none;">The Bugle</a> · <a href="https://soundcloud.com/the-bugle/bugle-179-playas-gon-play" title="Bugle 179 - Playas gon play" target="_blank" style="color: #cccccc; text-decoration: none;">Bugle 179 - Playas gon play</a></div>
</div>
<div class="border-top my-0">
<div class="d-flex justify-content-between py-2 px-4">
<button class="btn btn-link py-0 text-decoration-none text-muted">
<i class="far fa-heart mr-1"></i>
Like
</button>
<div>
<i class="far fa-comment cursor-pointer text-muted mr-1"></i>
Comment
</div>
<div>
<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
Share
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> -->
<!-- YOUTUBE -->
<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
<div class="card shadow-none border rounded-0">
<div class="card-body pb-0">
<div class="media">
<img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
<div class="media-body">
<div class="pl-2 d-flex align-items-top">
<div>
<p class="mb-0">
<a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
dansup
</a>
</p>
<p class="mb-0">
<a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
<span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
<span class="text-muted small">
<i class="fas fa-globe"></i>
</span>
</p>
</div>
<span class="text-right" style="flex-grow: 1;">
<button type="button" class="btn btn-link text-dark py-0">
<span class="fas fa-ellipsis-h text-lighter"></span>
<span class="sr-only">Post Menu</span>
</button>
</span>
</div>
</div>
</div>
<div>
<div>
<div class="">
<p class="pt-2 text-break" style="font-size: 15px;">
What does everyone think??
</p>
<div class="my-3 row p-0 m-0">
<iframe width="100%" height="315" src="https://www.youtube.com/embed/lH78Tb0r_f8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</div>
<div class="border-top my-0">
<div class="d-flex justify-content-between py-2 px-4">
<button class="btn btn-link py-0 text-decoration-none text-muted">
<i class="far fa-heart mr-1"></i>
Like
</button>
<div>
<i class="far fa-comment cursor-pointer text-muted mr-1"></i>
Comment
</div>
<div>
<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
Share
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> -->
<!-- PHOTOS -->
<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
<div class="card shadow-none border rounded-0">
<div class="card-body pb-0">
<div class="media">
<img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
<div class="media-body">
<div class="pl-2 d-flex align-items-top">
<div>
<p class="mb-0">
<a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
dansup
</a>
</p>
<p class="mb-0">
<a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
<span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
<span class="text-muted small">
<i class="fas fa-globe"></i>
</span>
</p>
</div>
<span class="text-right" style="flex-grow: 1;">
<button type="button" class="btn btn-link text-dark py-0">
<span class="fas fa-ellipsis-h text-lighter"></span>
<span class="sr-only">Post Menu</span>
</button>
</span>
</div>
</div>
</div>
<div>
<div>
<div class="">
<p class="pt-2 text-break" style="font-size: 15px;">
What does everyone think??
</p>
<div class="mb-1 row px-3">
<div class="col px-0">
<img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
</div>
<div class="col px-0">
<img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
</div>
</div>
<div class="mb-3 row px-3">
<div class="col px-0">
<img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
</div>
<div class="col px-0">
<img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
</div>
</div>
<div class="border-top my-0">
<div class="d-flex justify-content-between py-2 px-4">
<button class="btn btn-link py-0 text-decoration-none text-muted">
<i class="far fa-heart mr-1"></i>
Like
</button>
<div>
<i class="far fa-comment cursor-pointer text-muted mr-1"></i>
Comment
</div>
<div>
<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
Share
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> -->
<group-status
v-for="(status, index) in feed"
:key="'gs:' + status.id + index"
:prestatus="status"
:profile="profile"
:group-id="groupId"
v-on:comment-focus="commentFocus(index)"
v-on:status-delete="statusDelete(index)"
v-on:likes-modal="showLikesModal(index)" />
<b-modal
ref="likeBox"
size="sm"
centered
hide-footer
title="Likes"
body-class="list-group-flush p-0">
<div class="list-group py-1" style="max-height:300px;overflow-y:auto;">
<div
class="list-group-item border-top-0 border-left-0 border-right-0 py-2"
:class="{ 'border-bottom-0': index + 1 == likes.length }"
v-for="(user, index) in likes" :key="'modal_likes_'+index">
<div class="media align-items-center">
<a :href="user.url">
<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + 's avatar'" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
</a>
<div class="media-body">
<p class="mb-0" style="font-size: 14px">
<a :href="user.url" class="font-weight-bold text-dark">
{{user.username}}
</a>
</p>
<p v-if="!user.local" class="text-muted mb-0 text-truncate mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
</p>
<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
{{user.display_name}}
</p>
</div>
</div>
</div>
<infinite-loading @infinite="infiniteLikesHandler" :distance="800" spinner="spiral">
<div slot="no-more"></div>
<div slot="no-results"></div>
</infinite-loading>
</div>
</b-modal>
<div v-if="feed.length > 2" :distance="800">
<infinite-loading @infinite="infiniteFeed">
<div slot="no-more"></div>
<div slot="no-results"></div>
</infinite-loading>
</div>
</div>
</div>
<div v-else>
<div class="card card-body mt-3 shadow-none border d-flex align-items-center justify-content-center" style="height: 100px;">
<p class="lead mb-0">Join to participate in this group.</p>
</div>
</div>
</div>
<div class="col-12 col-md-5">
<group-info-card :group="group" />
</div>
</div>
<search-modal ref="searchModal" :group="group" :profile="profile" />
<invite-modal ref="inviteModal" :group="group" :profile="profile" />
</div>
</div>
</div>
</template>
<script type="text/javascript">
import StatusCard from '~/partials/StatusCard.vue';
import GroupCompose from './partials/GroupCompose.vue';
import GroupStatus from './partials/GroupStatus.vue';
import GroupInfoCard from './partials/GroupInfoCard.vue';
import LeaveGroup from './partials/LeaveGroup.vue';
import SearchModal from './partials/GroupSearchModal.vue';
import InviteModal from './partials/GroupInviteModal.vue';
import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
export default {
props: {
groupId: {
type: String
},
path: {
type: String
},
permalinkMode: {
type: Boolean,
default: false
},
permalinkId: {
type: String,
}
},
components: {
'status-card': StatusCard,
'group-status': GroupStatus,
'group-compose': GroupCompose,
'group-info-card': GroupInfoCard,
'leave-group': LeaveGroup,
'search-modal': SearchModal,
'invite-modal': InviteModal,
'group-banner': GroupBanner,
'group-header-details': GroupHeaderDetails,
'group-nav-tabs': GroupNavTabs,
},
data() {
return {
initalLoad: false,
profile: undefined,
group: {},
isMember: false,
isAdmin: false,
tab: 'feed',
requestingMembership: false,
composeText: null,
feed: [],
ids: [],
maxId: null,
status: undefined,
likes: [],
likesPage: 1,
likesId: undefined,
renderIdx: 1,
atabs: {
moderation_count: 0,
request_count: 0
}
};
},
created() {
this.fetchSelf();
},
methods: {
fetchSelf() {
axios.get('/api/v1/accounts/verify_credentials?_pe=1')
.then(res => {
this.profile = res.data;
})
.catch(err => {
window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
})
.finally(() => {
this.fetchGroup();
});
},
initObservers() {
// let video = document.querySelectorAll('video');
// let isPaused = false;
// let observer = new IntersectionObserver((entries, observer) => {
// entries.forEach(entry => {
// if (entry.intersectionRatio !=1 && !video.paused){
// video.pause();
// isPaused = true;
// }
// else if (isPaused) {
// video.play();
// isPaused = false
// }
// });
// }, {threshold: 1});
// observer.observe(video);
},
fetchGroup() {
axios.get('/api/v0/groups/' + this.groupId)
.then(res => {
this.group = res.data;
this.isMember = res.data.self.is_member;
this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
if(this.isAdmin) {
this.fetchAdminTabs();
}
if(this.path) {
if(this.isMember && ['about', 'topics', 'members', 'events', 'media', 'polls'].includes(this.path)) {
setTimeout(() => {
this.tab = this.path;
this.initalLoad = true;
}, 500);
} else if (this.isAdmin && ['insights', 'moderation'].includes(this.path)) {
setTimeout(() => {
this.tab = this.path;
this.initalLoad = true;
}, 500);
} else {
history.pushState(null, null, this.group.url);
this.initalLoad = true;
}
} else {
this.initalLoad = true;
}
})
.catch(err => {
window.location.href = '/groups/unavailable';
})
.finally(() => {
this.fetchFeed();
});
},
fetchAdminTabs() {
axios.get('/api/v0/groups/' + this.groupId + '/atabs')
.then(res => {
this.atabs = res.data;
})
},
fetchFeed() {
axios.get('/api/v0/groups/' + this.groupId + '/feed')
.then(res => {
let self = this;
if(res.data && res.data.length) {
this.feed = res.data;
this.maxId = this.feed[this.feed.length - 1].id;
res.data.forEach(d => {
if(self.ids.indexOf(d.id) == -1) {
self.ids.push(d.id);
}
});
}
this.initObservers();
})
},
fetchPermalink() {
axios.get('/api/v0/groups/status', {
params: {
gid: this.groupId,
sid: this.permalinkId
}
}).then(res => {
this.status = res.data;
if(this.status.in_reply_to_id) {
this.status.showCommentDrawer = true;
}
}).catch(err => {
this.permalinkMode = false;
this.fetchFeed();
});
},
handleRefresh() {
this.initialLoad = false;
this.init();
this.renderIdx++;
},
timestampFormat(date, showTime = false) {
let ts = new Date(date);
return showTime ? ts.toDateString() + ' · ' + ts.toLocaleTimeString() : ts.toDateString();
},
switchTab(tab) {
window.scrollTo(0,0);
if(tab == 'feed' && this.permalinkMode) {
this.permalinkMode = false;
this.fetchFeed();
}
let url = tab == 'feed' ? this.group.url : this.group.url + '/' + tab;
history.pushState(tab, null, url);
this.tab = tab;
},
joinGroup() {
this.requestingMembership = true;
axios.post('/api/v0/groups/'+this.groupId+'/join')
.then(res => {
this.requestingMembership = false;
this.group = res.data;
this.fetchGroup();
this.fetchFeed();
}).catch(err => {
let body = err.response;
if(body.status == 422) {
this.tab = 'feed';
history.pushState('', null, this.group.url);
this.requestingMembership = false;
swal('Oops!', body.data.error, 'error');
}
});
},
cancelJoinRequest() {
if(!window.confirm('Are you sure you want to cancel your request to join this group?')) {
return;
}
axios.post('/api/v0/groups/'+this.groupId+'/cjr')
.then(res => {
this.requestingMembership = false;
}).catch(err => {
let body = err.response;
if(body.status == 422) {
swal('Oops!', body.data.error, 'error');
}
});
},
leaveGroup() {
if(!window.confirm('Are you sure you want to leave this group? Any content you shared will remain accessible. You won\'t be able to rejoin for 24 hours.')) {
return;
}
axios.post('/api/v0/groups/'+this.groupId+'/leave')
.then(res => {
this.tab = 'feed';
history.pushState('', null, this.group.url);
this.feed = [];
this.isMember = false;
this.isAdmin = false;
this.group.self.role = null;
this.group.self.is_member = false;
});
},
pushNewStatus(status) {
this.feed.unshift(status);
},
commentFocus(index) {
let status = this.feed[index];
status.showCommentDrawer = true;
},
statusDelete(index) {
this.feed.splice(index, 1);
},
infiniteFeed($state) {
if(this.feed.length < 3) {
$state.complete();
return;
}
let apiUrl = '/api/v0/groups/' + this.groupId + '/feed';
axios.get(apiUrl, {
params: {
limit: 6,
max_id: this.maxId
},
}).then(res => {
if (res.data.length) {
// let self = this;
// data.forEach(d => {
// if(self.ids.indexOf(d.id) == -1) {
// if(self.maxId >= d.id) {
// self.maxId = d.id;
// }
// self.ids.push(d.id);
// self.feed.push(d);
// }
// });
let posts = res.data.filter(p => this.ids.indexOf(p.id) == -1);
this.maxId = posts[posts.length - 1].id;
this.feed.push(...posts);
this.ids.push(...posts.map(p => p.id));
setTimeout(() => {
this.initObservers();
}, 1000);
$state.loaded();
} else {
$state.complete();
}
});
},
decrementModCounter(amount) {
let count = this.atabs.moderation_count;
if(count == 0) {
return;
}
this.atabs.moderation_count = (count - amount);
},
setModCounter(amount) {
this.atabs.moderation_count = amount;
},
decrementJoinRequestCount(amount = 1) {
let count = this.atabs.request_count;
this.atabs.request_count = (count - amount)
},
incrementMemberCount() {
let count = this.group.member_count;
this.group.member_count = (count + 1);
},
copyLink() {
window.App.util.clipboard(this.group.url);
this.$bvToast.toast(`Succesfully copied group url to clipboard`, {
title: 'Success',
variant: 'success',
autoHideDelay: 5000
});
},
reportGroup() {
swal('Report Group', 'Are you sure you want to report this group?')
.then(res => {
if(res) {
location.href = `/i/report?id=${this.group.id}&type=group`;
}
});
},
showSearchModal() {
event.currentTarget.blur();
this.$refs.searchModal.open();
},
showInviteModal() {
event.currentTarget.blur();
this.$refs.inviteModal.open();
},
showLikesModal(index) {
this.likesId = this.feed[index].id;
axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.likesId)
.then(res => {
this.likes = res.data;
this.likesPage++;
this.$refs.likeBox.show();
});
},
infiniteLikesHandler($state) {
if(this.likes.length < 3) {
$state.complete();
return;
}
axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.likesId, {
params: {
page: this.likesPage,
},
}).then(res => {
if (res.data.length > 0) {
this.likes.push(...res.data);
this.likesPage++;
if(res.data.length != 10) {
$state.complete();
} else {
$state.loaded();
}
} else {
$state.complete();
}
});
},
}
}
</script>
<style lang="scss">
.group-feed-component {
&-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 1rem 0;
background-color: transparent;
.cta-btn {
width: 190px;
}
}
.header-jumbotron {
background-color: #F3F4F6;
height: 320px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
&-menu {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
&-nav {
.nav-item {
.nav-link {
padding-top: 1rem;
padding-bottom: 1rem;
color: #6c757d;
&.active {
color: #2c78bf;
border-bottom: 2px solid #2c78bf;
}
}
}
&:not(last-child) {
.nav-item {
margin-right: 14px;
}
}
}
}
&-body {
min-height: 40vh;
}
.member-label {
padding: 2px 5px;
font-size: 12px;
color: rgba(75, 119, 190, 1);
background:rgba(137, 196, 244, 0.2);
border:1px solid rgba(137, 196, 244, 0.3);
font-weight:400;
text-transform: capitalize;
}
.dropdown-item {
font-weight: 600;
}
.remote-label {
padding: 2px 5px;
font-size: 12px;
color: #B45309;
background: #FEF3C7;
border: 1px solid #FCD34D;
font-weight: 400;
text-transform: capitalize;
}
}
</style>

View file

@ -0,0 +1,217 @@
<template>
<div class="group-invite-component">
<div class="container">
<div class="row justify-content-center mt-5">
<div class="col-12 col-md-7">
<div class="card shadow-none border" style="min-height: 300px;">
<div class="card-body d-flex justify-content-center align-items-center">
<transition-group name="fade">
<div v-if="tab === 'initial'" key="initial">
<p class="text-center mb-1"><b-spinner variant="lighter" /></p>
<p class="text-center small text-muted mb-0">{{ loadingStatus }}</p>
</div>
<div v-else-if="tab === 'loading'" key="loading">
<p class="text-center mb-1"><b-spinner variant="lighter" /></p>
</div>
<div v-else-if="tab === 'login'" key="login">
<p class="text-center mb-0">Please <a href="/login">login</a> to continue</p>
</div>
<div v-else-if="tab === 'form'" key="form">
<div class="d-flex justify-content-center align-items-center flex-column">
<p class="text-center h4 font-weight-bold"><a href="#">@dansup</a> invited you to join</p>
<div class="card my-3 shadow-none border" style="width: 300px;">
<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: 100px;object-fit: cover;">
<div v-else class="card-img-top" style="width: 100px; height: 100px;padding: 5px;">
<div class="bg-primary d-flex align-items-center justify-content-center" style="width: 100%; height:100%;">
<i class="fal fa-users text-white fa-lg"></i>
</div>
</div>
<div class="card-body">
<p class="h5 font-weight-bold mb-1 text-dark">
{{ group.name || 'Untitled Group' }}
</p>
<transition name="fade">
<p v-if="showMore" class="text-muted small mb-1">
{{ group.description }}
</p>
</transition>
<p class="mb-1">
<span class="text-muted mr-2">
<i class="far fa-users fa-sm text-lighter mr-1"></i>
<span class="small font-weight-bold">{{ prettyCount(group.member_count) }} Members</span>
</span>
<span v-if="!group.local" class="remote-label ml-2">
<i class="fal fa-globe"></i> Remote
</span>
</p>
<transition name="fade">
<div v-if="showMore">
<p class="text-muted small mb-1">
<i class="far fa-tag fa-sm text-lighter mr-2"></i>
<span class="font-weight-bold">Category: {{ group.category.name }}</span>
</p>
<p class="text-muted small mb-1">
<i class="far fa-clock fa-sm text-lighter mr-2"></i>
<span class="font-weight-bold">Created {{ timeago(group.created_at) }} ago</span>
</p>
</div>
</transition>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<button class="btn btn-light border-lighter font-weight-bold btn-sm" @click="showMoreInfo">
{{ showMore ? 'Less' :'More' }} info
</button>
<div>
<button class="btn btn-light font-weight-bold btn-sm" @click="declineInvite">Decline</button>
<button class="btn btn-primary font-weight-bold btn-sm" @click="acceptInvite">Accept</button>
</div>
</div>
<!-- <p class="text-center h4 font-weight-bold">by <a href="#" class="font-weight-bold">@dansup</a></p> -->
</div>
<div v-else-if="tab === 'existingmember'" key="existingmember">
<p class="text-center mb-0">You already are a member of this group</p>
<p class="text-center mb-0">
<a :href="group.url" class="font-weight-bold">View Group</a>
</p>
</div>
<div v-else-if="tab === 'notinvited'" key="notinvited">
<p class="text-center mb-0">We cannot find an active invitation for your account.</p>
</div>
<div v-else-if="tab === 'error'" key="error">
<p class="text-center mb-0">An unknown error occured. Please try again later.</p>
</div>
<div v-else key="unknown">
<p class="text-center mb-0">An unknown error occured. Please try again later.</p>
</div>
</transition-group>
</div>
</div>
<!-- <h4>You've been invited to {{ group.name }}</h4> -->
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: [
'id'
],
data() {
return {
loadingStatus: 'Determining invite eligibility',
tab: 'initial',
profile: {},
group: {},
showMore: false
}
},
mounted() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.profile = res.data;
this.fetchGroup();
}).catch(err => {
if(err.response.status === 403) {
this.tab = 'login';
return;
} else {
this.tab = 'error';
return
}
});
},
methods: {
fetchGroup() {
axios.get(`/api/v0/groups/${this.id}`)
.then(res => {
this.group = res.data;
this.loadingStatus = 'Checking group invitations';
this.checkForInvitation();
}).catch(err => {
this.tab = 'error';
});
},
checkForInvitation() {
axios.post(`/api/v0/groups/${this.group.id}/invite/check`)
.then(res => {
this.tab = res.data.can_join == true ? 'form' : 'notinvited';
}).catch(err => {
if(err.response.status === 422 && err.response.data.error === 'Already a member') {
this.tab = 'existingmember';
} else {
this.tab = 'error';
}
})
},
prettyCount(val) {
return App.util.format.count(val);
},
timeago(ts) {
return App.util.format.timeAgo(ts);
},
showMoreInfo() {
event.currentTarget.blur();
this.showMore = !this.showMore;
},
acceptInvite() {
event.currentTarget.blur();
this.tab = 'loading';
axios.post(`/api/v0/groups/${this.group.id}/invite/accept`)
.then(res => {
setTimeout(() => {
location.href = res.data.next_url;
}, 2000);
}).catch(err => {
this.tab = 'error';
});
},
declineInvite() {
event.currentTarget.blur();
this.tab = 'loading';
axios.post(`/api/v0/groups/${this.group.id}/invite/decline`)
.then(res => {
location.href = res.data.next_url;
}).catch(err => {
this.tab = 'error';
});
},
}
}
</script>
<style lang="scss">
.group-invite-component {
.btn-light {
border-color: #E5E7EB;
}
}
</style>

View file

@ -0,0 +1,379 @@
<template>
<div class="group-profile-component w-100 h-100">
<div class="bg-white mb-3 border-bottom">
<div class="container-xl header">
<div class="header-jumbotron"></div>
<div class="header-profile-card">
<img :src="profile.avatar" class="avatar" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
<p class="name">
{{ profile.display_name }}
</p>
<p class="username text-muted">
<span v-if="profile.local">&commat;{{ profile.username }}</span>
<span v-else>{{ profile.acct }}</span>
<span v-if="profile.is_admin" class="text-danger ml-1" title="Site administrator" data-toggle="tooltip" data-placement="bottom"><i class="far fa-users-crown"></i></span>
</p>
</div>
<!-- <hr> -->
<div class="header-navbar">
<div></div>
<div>
<a
v-if="currentProfile.id === profile.id"
class="btn btn-light font-weight-bold mr-2"
href="/settings/home">
<i class="fas fa-edit mr-1"></i> Edit Profile
</a>
<a
v-if="profile.relationship.following"
class="btn btn-primary font-weight-bold mr-2"
:href="profile.url">
<i class="far fa-comment-alt-dots mr-1"></i> Message
</a>
<a
v-if="profile.relationship.following"
class="btn btn-light font-weight-bold mr-2"
:href="profile.url">
<i class="fas fa-user-check mr-1"></i> {{ profile.relationship.followed_by ? 'Friends' : 'Following' }}
</a>
<a
v-if="!profile.relationship.following"
class="btn btn-light font-weight-bold mr-2"
:href="profile.url">
<i class="fas fa-user mr-1"></i> View Main Profile
</a>
<div class="dropdown">
<button class="btn btn-light font-weight-bold dropdown-toggle" type="button" id="amenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-h"></i>
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="amenu">
<a v-if="currentProfile.id != profile.id" class="dropdown-item font-weight-bold" :href="`/i/report?type=user&id=${profile.id}`">Report</a>
<a v-if="currentProfile.id == profile.id" class="dropdown-item font-weight-bold" href="#">Leave Group</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="w-100 h-100 group-profile-feed">
<div class="container-xl">
<div class="row">
<div class="col-12 col-md-5">
<div class="card card-body shadow-sm infolet">
<h5 class="font-weight-bold mb-3">Intro</h5>
<div v-if="!profile.local" class="media mb-3 align-items-center">
<div class="media-icon">
<i class="far fa-globe" title="User is from a remote server" data-toggle="tooltip" data-placement="bottom"></i>
</div>
<div class="media-body">
Remote member from <strong>{{ profile.acct.split('@')[1] }}</strong>
</div>
</div>
<!-- <div v-if="profile.note.length" class="media mb-3 align-items-center">
<i class="far fa-book-user fa-lg text-lighter mr-3" title="User bio" data-toggle="tooltip" data-placement="bottom"></i>
<div class="media-body">
<span v-html="profile.note"></span>
</div>
</div> -->
<div class="media align-items-center">
<div class="media-icon">
<i class="fas fa-users" title="User joined group on this date" data-toggle="tooltip" data-placement="bottom"></i>
</div>
<div class="media-body">
{{ roleTitle }} of <strong>{{ group.name }}</strong> since {{ profile.group.joined }}
</div>
</div>
</div>
<div v-if="canIntersect" class="card card-body shadow-sm infolet">
<h5 class="font-weight-bold mb-3">Things in Common</h5>
<div v-if="commonIntersects.friends.length" class="media mb-3 align-items-center" v-once>
<div class="media-icon">
<i class="far fa-user-friends"></i>
</div>
<div class="media-body">
{{ commonIntersects.friends_count }} mutual friend<span v-if="commonIntersects.friends.length > 1">s</span> including
<span v-for="(friend, index) in commonIntersects.friends"><a :href="friend.url" class="text-dark font-weight-bold">{{ friend.acct }}</a><span v-if="commonIntersects.friends.length > index + 1">, </span><span v-else> </span></span>
<!-- <a href="#" class="text-dark font-weight-bold">dansup</a>, <a href="#" class="text-dark font-weight-bold">admin</a> and 1 other -->
</div>
</div>
<div class="media mb-3 align-items-center">
<div class="media-icon">
<i class="fas fa-home"></i>
</div>
<div class="media-body">
Lives in <strong>Canada</strong>
</div>
</div>
<div v-if="commonIntersects.groups.length" class="media mb-3 align-items-center">
<div class="media-icon">
<i class="fas fa-users"></i>
</div>
<div class="media-body">
Also member of <a :href="commonIntersects.groups[0].url" class="text-dark font-weight-bold">{{ commonIntersects.groups[0].name }}</a> and {{ commonIntersects.groups_count }} other groups
</div>
</div>
<div v-if="commonIntersects.topics.length" class="media mb-0 align-items-center">
<div class="media-icon">
<i class="far fa-thumbs-up fa-lg text-lighter"></i>
</div>
<div class="media-body">
Also interested in topics containing
<span v-for="(topic, index) in commonIntersects.topics">
<span v-if="commonIntersects.topics.length - 1 == index"> and </span><a :href="topic.url" class="font-weight-bold text-dark">#{{ topic.name }}</a><span v-if="commonIntersects.topics.length > index + 2">, </span>
</span> hashtags
</div>
</div>
</div>
</div>
<div class="col-12 col-md-7">
<div class="card card-body shadow-sm">
<h5 class="font-weight-bold mb-0">Group Posts</h5>
</div>
<div v-if="feedEmpty" class="pt-5 text-center">
<h5>No New Posts</h5>
<p>{{ profile.username }} hasn't posted anything yet in <strong>{{ group.name }}</strong>.</p>
<a :href="group.url" class="font-weight-bold">Go Back</a>
</div>
<div v-if="feedLoaded" class="mt-2">
<group-status
v-for="(status, index) in feed"
:key="'gps:' + status.id"
:permalinkMode="true"
:showGroupChevron="true"
:group="group"
:prestatus="status"
:profile="profile"
:group-id="group.id" />
<div v-if="feed.length >= 1" :distance="800">
<infinite-loading @infinite="infiniteFeed">
<div slot="no-more"></div>
<div slot="no-results"></div>
</infinite-loading>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import GroupStatus from '@/groups/partials/GroupStatus.vue';
export default {
props: {
pg: {
type: String
},
pp: {
type: String
}
},
components: {
'group-status': GroupStatus
},
data() {
return {
currentProfile: {},
roleTitle: 'Member',
group: {},
profile: {},
feed: [],
ids: [],
feedLoaded: false,
feedEmpty: false,
page: 1,
canIntersect: false,
commonIntersects: []
}
},
beforeMount() {
$('body').css('background-color', '#f0f2f5');
this.group = JSON.parse(this.pg);
this.profile = JSON.parse(this.pp);
if(this.profile.group.role == 'founder') {
this.roleTitle = 'Admin';
}
},
mounted() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.currentProfile = res.data;
this.fetchInitialFeed();
if(res.data.id != this.profile.id) {
this.fetchCommonIntersections();
}
})
this.$nextTick(() => {
$('[data-toggle="tooltip"]').tooltip();
});
},
methods: {
fetchInitialFeed() {
axios.get(`/api/v0/groups/${this.group.id}/user/${this.profile.id}/feed`)
.then(res => {
this.feed = res.data.filter(s => {
return s.pf_type != 'reply:text' || s.account.id != this.profile.id;
});
this.feedLoaded = true;
this.feedEmpty = this.feed.length == 0;
this.page++;
});
},
infiniteFeed($state) {
if(this.feed.length == 0) {
$state.complete();
return;
}
axios.get(`/api/v0/groups/${this.group.id}/user/${this.profile.id}/feed`, {
params: {
page: this.page
},
}).then(res => {
if (res.data.length) {
let data = res.data.filter(s => {
return s.pf_type != 'reply:text' || s.account.id != this.profile.id;
});
let self = this;
data.forEach(d => {
if(self.ids.indexOf(d.id) == -1) {
self.ids.push(d.id);
self.feed.push(d);
}
});
$state.loaded();
this.page++;
} else {
$state.complete();
}
});
},
fetchCommonIntersections() {
axios.get('/api/v0/groups/member/intersect/common', {
params: {
gid: this.group.id,
pid: this.profile.id
}
}).then(res => {
this.commonIntersects = res.data;
this.canIntersect = res.data.groups.length || res.data.topics.length;
});
}
}
}
</script>
<style lang="scss">
.group-profile-component {
background-color: #f0f2f5;
.header {
&-jumbotron {
background-color: #F3F4F6;
height: 320px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
&-profile-card {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.avatar {
width: 170px;
height: 170px;
border-radius: 50%;
margin-top: -150px;
margin-bottom: 20px;
}
.name {
font-size: 30px;
line-height: 30px;
font-weight: 700;
text-align: center;
margin-bottom: 6px;
}
.username {
font-size: 16px;
font-weight: 500;
text-align: center;
}
}
&-navbar {
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
border-top: 1px solid #F3F4F6;
.dropdown {
display: inline-block;
&-toggle:after {
display: none;
}
}
}
}
.group-profile-feed {
min-height: 500px;
}
.infolet {
margin-bottom: 1rem;
.media {
&-icon {
display: flex;
justify-content: center;
width: 30px;
margin-right: 10px;
i {
font-size: 1.1rem;
color: #D1D5DB !important;
}
}
}
}
.btn-light {
border-color: #F3F4F6;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,170 @@
<template>
<div class="group-topic-feed-component">
<div v-if="isLoaded" class="bg-white py-5 border-bottom">
<div class="container">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="font-weight-bold mb-1">#{{ name }}</h3>
<p class="mb-0 lead text-muted">
<span>
Posts in <a :href="group.url" class="text-muted font-weight-bold">{{ group.name }}</a>
</span>
<span>·</span>
<span><i class="fas fa-globe"></i></span>
<span>·</span>
<span>{{ group.membership != 'all' ? 'Private' : 'Public'}} Group</span>
</p>
</div>
<!-- <div>
<button class="btn btn-light border btn-lg text-muted">
<i class="fas fa-ellipsis-h"></i>
</button>
</div> -->
</div>
</div>
</div>
<div v-if="isLoaded" class="row justify-content-center mt-3">
<div v-if="feed.length" class="col-12 col-md-5">
<group-status
v-for="(status, index) in feed"
:key="'gs:' + status.id + index"
:prestatus="status"
:profile="profile"
:group="group"
:show-group-chevron="true"
:group-id="gid" />
<div v-if="feed.length > 2">
<infinite-loading @infinite="infiniteFeed">
<div slot="no-more"></div>
<div slot="no-results"></div>
</infinite-loading>
</div>
</div>
<div v-else class="col-12 col-md-5 d-flex justify-content-center">
<div class="mt-5">
<p class="text-lighter text-center">
<i class="fal fa-exclamation-circle fa-4x"></i>
</p>
<p class="font-weight-bold text-muted">Cannot load any posts containg the <span class="font-weight-normal">#{{ name }}</span> hashtag</p>
<p class="text-left">
This can happen for a few reasons:
</p>
<ul class="text-left">
<li>There is a typo in the url</li>
<li>No posts exist that contain this hashtag</li>
<li>This hashtag has been banned by group admins</li>
<li>The hashtag is new or used infrequently</li>
<li>A technical issue has occured</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import GroupStatus from '@/groups/partials/GroupStatus.vue';
export default {
props: {
gid: {
type: String
},
name: {
type: String
}
},
components: {
GroupStatus
},
data() {
return {
isLoaded: false,
group: false,
profile: false,
feed: [],
page: 1,
ids: []
}
},
mounted() {
this.fetchProfile();
},
methods: {
fetchProfile() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.profile = res.data;
this.fetchGroup();
});
},
fetchGroup() {
axios.get('/api/v0/groups/' + this.gid)
.then(res => {
this.group = res.data;
this.fetchFeed();
});
},
fetchFeed() {
axios.get('/api/v0/groups/topics/tag', {
params: {
gid: this.gid,
name: this.name
}
}).then(res => {
this.feed = res.data;
this.isLoaded = true;
let self = this;
res.data.forEach(d => {
if(self.ids.indexOf(d.id) == -1) {
self.ids.push(d.id);
}
});
this.page++;
})
},
infiniteFeed($state) {
if(this.feed.length < 2) {
$state.complete();
return;
}
axios.get('/api/v0/groups/topics/tag', {
params: {
gid: this.gid,
name: this.name,
limit: 1,
page: this.page
},
}).then(res => {
if (res.data.length) {
let data = res.data;
let self = this;
data.forEach(d => {
if(self.ids.indexOf(d.id) == -1) {
self.ids.push(d.id);
self.feed.push(d);
}
});
$state.loaded();
this.page++;
} else {
$state.complete();
}
});
}
}
}
</script>

View file

@ -0,0 +1,473 @@
<template>
<div class="groups-home-component w-100 h-100">
<div v-if="initialLoad" class="row border-bottom m-0 p-0">
<div class="col-2 shadow" style="height: 100vh;background:#fff;top:51px;overflow: hidden;z-index: 1;position: sticky;">
<div class="p-1">
<div class="d-flex justify-content-between align-items-center py-3">
<p class="h2 font-weight-bold mb-0">Groups</p>
<a class="btn btn-light px-2 rounded-circle" href="/settings/home">
<i class="fas fa-cog fa-lg"></i>
</a>
</div>
<div class="mb-3">
<autocomplete
:search="autocompleteSearch"
placeholder="Search groups by name"
aria-label="Search groups by name"
:get-result-value="getSearchResultValue"
:debounceTime="700"
@submit="onSearchSubmit"
ref="autocomplete"
>
<template #result="{ result, props }">
<li
v-bind="props"
class="autocomplete-result"
>
<div class="media align-items-center">
<img v-if="result.local && result.metadata && result.metadata.hasOwnProperty('header') && result.metadata.header.hasOwnProperty('url')" :src="result.metadata.header.url" width="32" height="32">
<div v-else class="icon-placeholder">
<i class="fal fa-user-friends"></i>
</div>
<div class="media-body text-truncate mr-3">
<p class="result-name mb-n1 font-weight-bold">
{{ truncateName(result.name) }}
<span v-if="result.verified" class="fa-stack ml-n2" title="Verified Group" data-toggle="tooltip">
<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
</span>
</p>
<p class="mb-0 text-muted" style="font-size: 10px;">
<span v-if="!result.local" title="Remote Group">
<i class="far fa-globe"></i>
</span>
<span v-if="!result.local">·</span>
<span class="font-weight-bold">{{ result.member_count }} members</span>
</p>
</div>
</div>
</li>
</template>
</autocomplete>
</div>
<button
class="btn btn-light group-nav-btn"
:class="{ active: tab == 'feed' }"
@click="switchTab('feed')">
<div class="group-nav-btn-icon">
<i class="fas fa-list"></i>
</div>
<div class="group-nav-btn-name">
Your Feed
</div>
</button>
<button
class="btn btn-light group-nav-btn"
:class="{ active: tab == 'discover' }"
@click="switchTab('discover')">
<div class="group-nav-btn-icon">
<i class="fas fa-compass"></i>
</div>
<div class="group-nav-btn-name">
Discover
</div>
</button>
<button
class="btn btn-light group-nav-btn"
:class="{ active: tab == 'mygroups' }"
@click="switchTab('mygroups')">
<div class="group-nav-btn-icon">
<i class="fas fa-list"></i>
</div>
<div class="group-nav-btn-name">
My Groups
</div>
</button>
<button
class="btn btn-light group-nav-btn"
:class="{ active: tab == 'notifications' }"
@click="switchTab('notifications')">
<div class="group-nav-btn-icon">
<i class="far fa-bell"></i>
</div>
<div class="group-nav-btn-name">
Your Notifications
</div>
</button>
<!-- <button
class="btn btn-light group-nav-btn"
:class="{ active: tab == 'invitations' }"
@click="switchTab('invitations')">
<div class="group-nav-btn-icon">
<i class="fas fa-user-plus"></i>
</div>
<div class="group-nav-btn-name">
Group Invitations
</div>
</button> -->
<button
class="btn btn-light group-nav-btn"
:class="{ active: tab == 'remotesearch' }"
@click="switchTab('remotesearch')">
<div class="group-nav-btn-icon">
<i class="fas fa-search-plus"></i>
</div>
<div class="group-nav-btn-name">
Find a remote group
</div>
</button>
<button
v-if="config && config.limits.user.create.new"
class="btn btn-primary btn-block rounded-pill font-weight-bold mt-3"
@click="switchTab('creategroup')"
:disabled="tab == 'creategroup'">
<i class="fas fa-plus mr-2"></i> Create New Group
</button>
<hr>
<div v-for="group in groups" class="ml-2">
<div class="card shadow-sm border text-decoration-none text-dark">
<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: auto;object-fit: cover;max-height: 160px;">
<div v-else class="bg-primary" style="width:100%;height:160px;"></div>
<div class="card-body">
<div class="lead font-weight-bold d-flex align-items-top" style="height: 60px;">
{{ group.name }}
<span v-if="group.verified" class="fa-stack ml-n2 mt-n2">
<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
</span>
</div>
<div class="text-muted font-weight-light d-flex justify-content-between">
<span>{{group.member_count}} Members</span>
<span style="font-size: 12px;padding: 2px 5px;color: rgba(75, 119, 190, 1);background:rgba(137, 196, 244, 0.2);border:1px solid rgba(137, 196, 244, 0.3);font-weight:400;text-transform: capitalize;" class="rounded">{{ group.self.role }}</span>
</div>
<hr>
<p class="mb-0">
<a class="btn btn-light btn-block border rounded-lg font-weight-bold" :href="group.url">View Group</a>
</p>
</div>
</div>
</div>
</div>
</div>
<keep-alive>
<transition name="fade">
<self-feed v-if="tab == 'feed'" :profile="profile" v-on:switchtab="switchTab" />
<self-discover v-if="tab == 'discover'" :profile="profile" />
<self-notifications v-if="tab == 'notifications'" :profile="profile" />
<self-invitations v-if="tab == 'invitations'" :profile="profile" />
<self-remote-search v-if="tab == 'remotesearch'" :profile="profile" />
<self-groups v-if="tab == 'mygroups'" :profile="profile" />
<create-group v-if="tab == 'creategroup'" :profile="profile" />
<div v-if="tab == 'gsearch'">
<div class="col-12 px-5">
<div class="my-4">
<p class="h1 font-weight-bold mb-1">Group Search</p>
<p class="lead text-muted mb-0">Search and explore groups.</p>
</div>
<div class="media align-items-center text-lighter">
<i class="far fa-chevron-left fa-lg mr-3"></i>
<div class="media-body">
<p class="lead mb-0">Use the search bar on the side menu</p>
</div>
</div>
</div>
</div>
</transition>
</keep-alive>
</div>
<div v-else class="row justify-content-center mt-5">
<b-spinner />
</div>
</div>
</template>
<script type="text/javascript">
import GroupStatus from './partials/GroupStatus.vue';
import SelfFeed from './partials/SelfFeed.vue';
import SelfDiscover from './partials/SelfDiscover.vue';
import SelfGroups from './partials/SelfGroups.vue';
import SelfNotifications from './partials/SelfNotifications.vue';
import SelfInvitations from './partials/SelfInvitations.vue';
import SelfRemoteSearch from './partials/SelfRemoteSearch.vue';
import CreateGroup from './CreateGroup.vue';
import Autocomplete from '@trevoreyre/autocomplete-vue'
import '@trevoreyre/autocomplete-vue/dist/style.css'
export default {
data() {
return {
initialLoad: false,
config: {},
groups: [],
profile: {},
tab: null,
searchQuery: undefined,
};
},
components: {
'autocomplete-input': Autocomplete,
'group-status': GroupStatus,
'self-discover': SelfDiscover,
'self-groups': SelfGroups,
'self-feed': SelfFeed,
'self-notifications': SelfNotifications,
'self-invitations': SelfInvitations,
'self-remote-search': SelfRemoteSearch,
"create-group": CreateGroup
},
mounted() {
this.fetchConfig();
},
methods: {
init() {
document.querySelectorAll("footer").forEach(e => e.parentNode.removeChild(e));
document.querySelectorAll(".mobile-footer-spacer").forEach(e => e.parentNode.removeChild(e));
document.querySelectorAll(".mobile-footer").forEach(e => e.parentNode.removeChild(e));
// let u = new URLSearchParams(window.location.search);
// if(u.has('ct')) {
// if(['mygroups', 'notifications', 'discover', 'remotesearch', 'creategroup', 'gsearch'].includes(u.get('ct'))) {
// if(u.get('ct') == 'creategroup' && this.config.limits.user.create.new == false) {
// this.tab = 'feed';
// history.pushState(null, null, '/groups/feed');
// } else {
// this.tab = u.get('ct');
// }
// } else {
// this.tab = 'feed';
// history.pushState(null, null, '/groups/feed');
// }
// } else {
// this.tab = 'feed';
// }
this.initialLoad = true;
},
fetchConfig() {
axios.get('/api/v0/groups/config')
.then(res => {
this.config = res.data;
this.fetchProfile();
});
},
fetchProfile() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.profile = res.data;
this.init();
window._sharedData.curUser = res.data;
window.App.util.navatar();
})
},
fetchSelfGroups() {
axios.get('/api/v0/groups/self/list')
.then(res => {
this.groups = res.data;
});
},
switchTab(tab) {
event.currentTarget.blur();
window.scrollTo(0,0);
this.tab = tab;
if(tab != 'feed') {
history.pushState(null, null, '/groups/home?ct=' + tab);
} else {
history.pushState(null, null, '/groups/home');
}
},
autocompleteSearch(input) {
if (!input || input.length < 2) {
if(this.tab = 'searchresults') {
this.tab = 'feed';
}
return [];
};
this.searchQuery = input;
// this.tab = 'searchresults';
if(input.startsWith('http')) {
let url = new URL(input);
if(url.hostname == location.hostname) {
location.href = input;
return [];
}
return [];
}
if(input.startsWith('#')) {
this.$bvToast.toast(input, {
title: 'Hashtag detected',
variant: 'info',
autoHideDelay: 5000
});
return [];
}
return axios.post('/api/v0/groups/search/global', {
q: input,
v: '0.2'
})
.then(res => {
this.searchLoading = false;
return res.data;
}).catch(err => {
if(err.response.status === 422) {
this.$bvToast.toast(err.response.data.error.message, {
title: 'Cannot display search results',
variant: 'danger',
autoHideDelay: 5000
});
}
return [];
})
},
getSearchResultValue(result) {
return result.name;
},
onSearchSubmit(result) {
if (result.length < 1) {
return [];
}
location.href = result.url;
},
truncateName(val) {
if(val.length < 24) {
return val;
}
return val.substr(0, 23) + '...';
}
}
}
</script>
<style lang="scss">
.groups-home-component {
font-family: var(--font-family-sans-serif);
.group-nav-btn {
display: block;
width: 100%;
padding-left: 0;
padding-top: 0.3rem;
padding-bottom: 0.3rem;
margin-bottom: 0.3rem;
border-radius: 1.5rem;
text-align: left;
color: #6c757d;
background-color: transparent;
border-color: transparent;
justify-content: flex-start;
&.active {
background-color: #EFF6FF !important;
border:1px solid #DBEAFE !important;
color: #212529;
.group-nav-btn-icon {
background-color: #2c78bf !important;
color: #fff !important;
}
}
&-icon {
display: inline-flex;
width: 35px;
height: 35px;
padding: 12px;
background-color: #E5E7EB;
border-radius: 17px;
margin: auto 0.3rem;
align-items: center;
justify-content: center;
}
&-name {
display: inline-block;
margin-left: 0.3rem;
font-weight: 700;
}
}
.autocomplete-input {
height: 2.375rem;
background-color: #f8f9fa !important;
font-size: 0.9rem;
color: #495057;
border-radius: 50rem;
border-color: transparent;
&:focus,
&[aria-expanded=true] {
box-shadow: none;
}
}
.autocomplete-result {
background: none;
padding: 12px;
&:hover,
&:focus {
background-color: #EFF6FF !important;
}
.media {
img {
object-fit: cover;
border-radius: 4px;
margin-right: 0.6rem;
}
.icon-placeholder {
display: flex;
width: 32px;
height: 32px;
background-color: #2c78bf;
border-radius: 4px;
justify-content: center;
align-items: center;
color: #fff;
margin-right: 0.6rem;
}
}
}
.autocomplete-result-list {
padding-bottom: 0;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 200ms;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
}
</style>

View file

@ -0,0 +1,168 @@
<template>
<div class="group-feed-component">
<div class="row border-bottom m-0 p-0">
<sidebar />
<div class="col-12 col-md-9 px-md-0">
<div class="bg-white mb-3 border-bottom">
<div>
<group-banner :group="group" />
<group-header-details
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
@refresh="handleRefresh"
/>
<group-nav-tabs
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
:atabs="atabs"
/>
</div>
</div>
<div v-if="!initialLoad">
<p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
</div>
<template v-else>
<div class="container-xl group-feed-component-body">
<template v-if="initialLoad && group.self.is_member">
<group-about :key="renderIdx" :group="group" :profile="profile" />
</template>
<member-only-warning v-else />
</div>
</template>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import StatusCard from '~/partials/StatusCard.vue';
import GroupMembers from '@/groups/partials/GroupMembers.vue';
import GroupCompose from '@/groups/partials/GroupCompose.vue';
import GroupStatus from '@/groups/partials/GroupStatus.vue';
import GroupAbout from '@/groups/partials/GroupAbout.vue';
import GroupMedia from '@/groups/partials/GroupMedia.vue';
import GroupModeration from '@/groups/partials/GroupModeration.vue';
import GroupTopics from '@/groups/partials/GroupTopics.vue';
import GroupInfoCard from '@/groups/partials/GroupInfoCard.vue';
import LeaveGroup from '@/groups/partials/LeaveGroup.vue';
import GroupInsights from '@/groups/partials/GroupInsights.vue';
import SearchModal from '@/groups/partials/GroupSearchModal.vue';
import InviteModal from '@/groups/partials/GroupInviteModal.vue';
import SidebarComponent from '@/groups/sections/Sidebar.vue';
import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
import MemberOnlyWarning from '@/groups/partials/Membership/MemberOnlyWarning.vue';
export default {
props: {
groupId: {
type: String
},
path: {
type: String
}
},
components: {
'status-card': StatusCard,
'group-about': GroupAbout,
'group-status': GroupStatus,
'group-members': GroupMembers,
'group-compose': GroupCompose,
'group-topics': GroupTopics,
'group-info-card': GroupInfoCard,
'group-media': GroupMedia,
'group-moderation': GroupModeration,
'leave-group': LeaveGroup,
'group-insights': GroupInsights,
'search-modal': SearchModal,
'invite-modal': InviteModal,
'sidebar': SidebarComponent,
'group-banner': GroupBanner,
'group-header-details': GroupHeaderDetails,
'group-nav-tabs': GroupNavTabs,
'member-only-warning': MemberOnlyWarning
},
data() {
return {
initialLoad: false,
profile: undefined,
group: {},
isMember: false,
isAdmin: false,
renderIdx: 1,
atabs: {
moderation_count: 0,
request_count: 0
}
};
},
created() {
this.init();
},
methods: {
init() {
this.initialLoad = false;
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.profile = res.data;
this.fetchGroup();
})
.catch(err => {
window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
});
},
handleRefresh() {
this.initialLoad = false;
this.init();
this.renderIdx++;
},
fetchGroup() {
axios.get('/api/v0/groups/' + this.groupId)
.then(res => {
this.group = res.data;
this.isMember = res.data.self.is_member;
this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
if(this.isAdmin) {
this.fetchAdminTabs();
}
this.initialLoad = true;
})
.catch(err => {
//window.location.href = '/groups/unavailable';
alert('error');
});
},
fetchAdminTabs() {
axios.get('/api/v0/groups/' + this.groupId + '/atabs')
.then(res => {
this.atabs = res.data;
})
}
}
}
</script>
<style lang="scss">
.group-feed-component {
&-body {
min-height: 40vh;
}
}
</style>

View file

@ -0,0 +1,168 @@
<template>
<div class="group-feed-component">
<div class="row border-bottom m-0 p-0">
<sidebar />
<div class="col-12 col-md-9 px-md-0">
<div class="bg-white mb-3 border-bottom">
<div>
<group-banner :group="group" />
<group-header-details
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
@refresh="handleRefresh"
/>
<group-nav-tabs
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
:atabs="atabs"
/>
</div>
</div>
<div v-if="!initialLoad">
<p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
</div>
<template v-else>
<div class="container-xl group-feed-component-body">
<template v-if="initialLoad && group.self.is_member">
<group-media :key="renderIdx" :group="group" :profile="profile" />
</template>
<member-only-warning v-else />
</div>
</template>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import StatusCard from '~/partials/StatusCard.vue';
import GroupMembers from '@/groups/partials/GroupMembers.vue';
import GroupCompose from '@/groups/partials/GroupCompose.vue';
import GroupStatus from '@/groups/partials/GroupStatus.vue';
import GroupAbout from '@/groups/partials/GroupAbout.vue';
import GroupMedia from '@/groups/partials/GroupMedia.vue';
import GroupModeration from '@/groups/partials/GroupModeration.vue';
import GroupTopics from '@/groups/partials/GroupTopics.vue';
import GroupInfoCard from '@/groups/partials/GroupInfoCard.vue';
import LeaveGroup from '@/groups/partials/LeaveGroup.vue';
import GroupInsights from '@/groups/partials/GroupInsights.vue';
import SearchModal from '@/groups/partials/GroupSearchModal.vue';
import InviteModal from '@/groups/partials/GroupInviteModal.vue';
import SidebarComponent from '@/groups/sections/Sidebar.vue';
import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
import MemberOnlyWarning from '@/groups/partials/Membership/MemberOnlyWarning.vue';
export default {
props: {
groupId: {
type: String
},
path: {
type: String
}
},
components: {
'status-card': StatusCard,
'group-about': GroupAbout,
'group-status': GroupStatus,
'group-members': GroupMembers,
'group-compose': GroupCompose,
'group-topics': GroupTopics,
'group-info-card': GroupInfoCard,
'group-media': GroupMedia,
'group-moderation': GroupModeration,
'leave-group': LeaveGroup,
'group-insights': GroupInsights,
'search-modal': SearchModal,
'invite-modal': InviteModal,
'sidebar': SidebarComponent,
'group-banner': GroupBanner,
'group-header-details': GroupHeaderDetails,
'group-nav-tabs': GroupNavTabs,
'member-only-warning': MemberOnlyWarning
},
data() {
return {
initialLoad: false,
profile: undefined,
group: {},
isMember: false,
isAdmin: false,
renderIdx: 1,
atabs: {
moderation_count: 0,
request_count: 0
}
};
},
created() {
this.init();
},
methods: {
init() {
this.initialLoad = false;
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.profile = res.data;
this.fetchGroup();
})
.catch(err => {
window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
});
},
handleRefresh() {
this.initialLoad = false;
this.init();
this.renderIdx++;
},
fetchGroup() {
axios.get('/api/v0/groups/' + this.groupId)
.then(res => {
this.group = res.data;
this.isMember = res.data.self.is_member;
this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
if(this.isAdmin) {
this.fetchAdminTabs();
}
this.initialLoad = true;
})
.catch(err => {
//window.location.href = '/groups/unavailable';
alert('error');
});
},
fetchAdminTabs() {
axios.get('/api/v0/groups/' + this.groupId + '/atabs')
.then(res => {
this.atabs = res.data;
})
}
}
}
</script>
<style lang="scss">
.group-feed-component {
&-body {
min-height: 40vh;
}
}
</style>

View file

@ -0,0 +1,168 @@
<template>
<div class="group-feed-component">
<div class="row border-bottom m-0 p-0">
<sidebar />
<div class="col-12 col-md-9 px-md-0">
<div class="bg-white mb-3 border-bottom">
<div>
<group-banner :group="group" />
<group-header-details
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
@refresh="handleRefresh"
/>
<group-nav-tabs
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
:atabs="atabs"
/>
</div>
</div>
<div v-if="!initialLoad">
<p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
</div>
<template v-else>
<div class="container-xl group-feed-component-body">
<template v-if="initialLoad && group.self.is_member">
<group-members :key="renderIdx" :group="group" :profile="profile" />
</template>
<member-only-warning v-else />
</div>
</template>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import StatusCard from '~/partials/StatusCard.vue';
import GroupMembers from '@/groups/partials/GroupMembers.vue';
import GroupCompose from '@/groups/partials/GroupCompose.vue';
import GroupStatus from '@/groups/partials/GroupStatus.vue';
import GroupAbout from '@/groups/partials/GroupAbout.vue';
import GroupMedia from '@/groups/partials/GroupMedia.vue';
import GroupModeration from '@/groups/partials/GroupModeration.vue';
import GroupTopics from '@/groups/partials/GroupTopics.vue';
import GroupInfoCard from '@/groups/partials/GroupInfoCard.vue';
import LeaveGroup from '@/groups/partials/LeaveGroup.vue';
import GroupInsights from '@/groups/partials/GroupInsights.vue';
import SearchModal from '@/groups/partials/GroupSearchModal.vue';
import InviteModal from '@/groups/partials/GroupInviteModal.vue';
import SidebarComponent from '@/groups/sections/Sidebar.vue';
import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
import MemberOnlyWarning from '@/groups/partials/Membership/MemberOnlyWarning.vue';
export default {
props: {
groupId: {
type: String
},
path: {
type: String
}
},
components: {
'status-card': StatusCard,
'group-about': GroupAbout,
'group-status': GroupStatus,
'group-members': GroupMembers,
'group-compose': GroupCompose,
'group-topics': GroupTopics,
'group-info-card': GroupInfoCard,
'group-media': GroupMedia,
'group-moderation': GroupModeration,
'leave-group': LeaveGroup,
'group-insights': GroupInsights,
'search-modal': SearchModal,
'invite-modal': InviteModal,
'sidebar': SidebarComponent,
'group-banner': GroupBanner,
'group-header-details': GroupHeaderDetails,
'group-nav-tabs': GroupNavTabs,
'member-only-warning': MemberOnlyWarning
},
data() {
return {
initialLoad: false,
profile: undefined,
group: {},
isMember: false,
isAdmin: false,
renderIdx: 1,
atabs: {
moderation_count: 0,
request_count: 0
}
};
},
created() {
this.init();
},
methods: {
init() {
this.initialLoad = false;
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.profile = res.data;
this.fetchGroup();
})
.catch(err => {
window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
});
},
handleRefresh() {
this.initialLoad = false;
this.init();
this.renderIdx++;
},
fetchGroup() {
axios.get('/api/v0/groups/' + this.groupId)
.then(res => {
this.group = res.data;
this.isMember = res.data.self.is_member;
this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
if(this.isAdmin) {
this.fetchAdminTabs();
}
this.initialLoad = true;
})
.catch(err => {
//window.location.href = '/groups/unavailable';
alert('error');
});
},
fetchAdminTabs() {
axios.get('/api/v0/groups/' + this.groupId + '/atabs')
.then(res => {
this.atabs = res.data;
})
}
}
}
</script>
<style lang="scss">
.group-feed-component {
&-body {
min-height: 40vh;
}
}
</style>

View file

@ -0,0 +1,168 @@
<template>
<div class="group-feed-component">
<div class="row border-bottom m-0 p-0">
<sidebar />
<div class="col-12 col-md-9 px-md-0">
<div class="bg-white mb-3 border-bottom">
<div>
<group-banner :group="group" />
<group-header-details
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
@refresh="handleRefresh"
/>
<group-nav-tabs
:group="group"
:isAdmin="isAdmin"
:isMember="isMember"
:atabs="atabs"
/>
</div>
</div>
<div v-if="!initialLoad">
<p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
</div>
<template v-else>
<div class="container-xl group-feed-component-body">
<template v-if="initialLoad && group.self.is_member">
<group-topics :key="renderIdx" :group="group" :profile="profile" />
</template>
<member-only-warning v-else />
</div>
</template>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import StatusCard from '~/partials/StatusCard.vue';
import GroupMembers from '@/groups/partials/GroupMembers.vue';
import GroupCompose from '@/groups/partials/GroupCompose.vue';
import GroupStatus from '@/groups/partials/GroupStatus.vue';
import GroupAbout from '@/groups/partials/GroupAbout.vue';
import GroupMedia from '@/groups/partials/GroupMedia.vue';
import GroupModeration from '@/groups/partials/GroupModeration.vue';
import GroupTopics from '@/groups/partials/GroupTopics.vue';
import GroupInfoCard from '@/groups/partials/GroupInfoCard.vue';
import LeaveGroup from '@/groups/partials/LeaveGroup.vue';
import GroupInsights from '@/groups/partials/GroupInsights.vue';
import SearchModal from '@/groups/partials/GroupSearchModal.vue';
import InviteModal from '@/groups/partials/GroupInviteModal.vue';
import SidebarComponent from '@/groups/sections/Sidebar.vue';
import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
import MemberOnlyWarning from '@/groups/partials/Membership/MemberOnlyWarning.vue';
export default {
props: {
groupId: {
type: String
},
path: {
type: String
}
},
components: {
'status-card': StatusCard,
'group-about': GroupAbout,
'group-status': GroupStatus,
'group-members': GroupMembers,
'group-compose': GroupCompose,
'group-topics': GroupTopics,
'group-info-card': GroupInfoCard,
'group-media': GroupMedia,
'group-moderation': GroupModeration,
'leave-group': LeaveGroup,
'group-insights': GroupInsights,
'search-modal': SearchModal,
'invite-modal': InviteModal,
'sidebar': SidebarComponent,
'group-banner': GroupBanner,
'group-header-details': GroupHeaderDetails,
'group-nav-tabs': GroupNavTabs,
'member-only-warning': MemberOnlyWarning
},
data() {
return {
initialLoad: false,
profile: undefined,
group: {},
isMember: false,
isAdmin: false,
renderIdx: 1,
atabs: {
moderation_count: 0,
request_count: 0
}
};
},
created() {
this.init();
},
methods: {
init() {
this.initialLoad = false;
axios.get('/api/pixelfed/v1/accounts/verify_credentials')
.then(res => {
this.profile = res.data;
this.fetchGroup();
})
.catch(err => {
window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
});
},
handleRefresh() {
this.initialLoad = false;
this.init();
this.renderIdx++;
},
fetchGroup() {
axios.get('/api/v0/groups/' + this.groupId)
.then(res => {
this.group = res.data;
this.isMember = res.data.self.is_member;
this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
if(this.isAdmin) {
this.fetchAdminTabs();
}
this.initialLoad = true;
})
.catch(err => {
//window.location.href = '/groups/unavailable';
alert('error');
});
},
fetchAdminTabs() {
axios.get('/api/v0/groups/' + this.groupId + '/atabs')
.then(res => {
this.atabs = res.data;
})
}
}
}
</script>
<style lang="scss">
.group-feed-component {
&-body {
min-height: 40vh;
}
}
</style>

View file

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

View file

@ -0,0 +1,405 @@
<template>
<div class="comment-post-component">
<div class="media media-status align-items-top mt-3">
<div v-if="commentBorderArrow" class="comment-border-arrow"></div>
<!-- <a
v-if="replyChildId == status.id"
href="#comment-1"
class="comment-border-link"
@click.prevent="replyToChild(status)">
<span class="sr-only">Jump to comment-{{ index }}</span>
</a> -->
<a :href="status.account.url">
<img class="rounded-circle media-avatar border" :src="status.account.avatar" width="32" height="32" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
</a>
<div class="media-body">
<div v-if="!status.media_attachments.length" class="media-body-comment">
<p class="media-body-comment-username">
<a :href="status.account.url">
{{status.account.acct}}
</a>
</p>
<read-more :status="status" />
</div>
<div v-else>
<p class="media-body-comment-username">
<a :href="status.account.url">
{{status.account.acct}}
</a>
</p>
<div class="bh-comment" @click="lightbox(status)">
<blur-hash-image
:width="blurhashWidth(status)"
:height="blurhashHeight(status)"
:punch="1"
class="img-fluid rounded-lg border shadow"
:hash="status.media_attachments[0].blurhash"
:src="getMediaSource(status)" />
</div>
</div>
<p class="media-body-reactions">
<a
v-if="profile"
href="#"
class="font-weight-bold"
:class="[ status.favourited ? 'text-primary' : 'text-muted' ]"
@click.prevent="likeComment(status, index, $event)">
{{ status.favourited ? 'Liked' : 'Like' }}
</a>
<!-- <span class="mx-1">·</span> -->
<!-- <a href="#" class="text-muted font-weight-bold" @click.prevent="replyToChild(status, index)">Reply</a> -->
<span v-if="profile" class="mx-1">·</span>
<a
class="font-weight-bold text-muted"
:href="status.url"
v-once>
{{ shortTimestamp(status.created_at) }}
</a>
<span v-if="profile && status.account.id === profile.id">
<span class="mx-1">·</span>
<a
class="font-weight-bold text-lighter"
href="#"
@click.prevent="deleteComment(index)">
Delete
</a>
</span>
</p>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import ReadMore from './ReadMore.vue';
export default {
props: {
groupId: {
type: String
},
profile: {
type: Object
},
status: {
type: Object,
},
commentBorderArrow: {
type: Boolean,
default: false
}
},
components: {
"read-more": ReadMore
},
data() {
return {
isLoaded: false,
hide: false,
feed: [],
canLoadMore: false,
isLoadingMore: false,
replyContent: null,
maxReplyId: null,
readMoreCursor: 200,
avatar: '/storage/avatars/default.png',
isUploading: false,
uploadProgress: 0,
lightboxStatus: null,
replyChildId: undefined,
childReplyContent: null,
postingChildComment: false
}
},
mounted() {
console.log(this.status);
// if(this.permalinkMode && this.permalinkStatus) {
// this.feed.push(this.permalinkStatus);
// this.isLoaded = true;
// this.canLoadMore = false;
// } else {
// this.fetchComments();
// }
// if(this.profile && this.profile.hasOwnProperty('avatar')) {
// this.avatar = this.profile.avatar;
// }
},
methods: {
fetchComments() {
axios.get('/api/v0/groups/comments', {
params: {
gid: this.groupId,
sid: this.status.id,
limit: 3
}
}).then(res => {
this.feed = res.data;
this.isLoaded = true;
this.maxReplyId = res.data[(res.data.length - 1)].id;
if(this.feed.length == 3) {
this.canLoadMore = true;
}
}).catch(err => {
this.isLoaded = true;
})
},
loadMoreComments() {
this.isLoadingMore = true;
axios.get('/api/v0/groups/comments', {
params: {
gid: this.groupId,
sid: this.status.id,
limit: 3,
max_id: this.maxReplyId
}
}).then(res => {
if(res.data[res.data.length - 1].id == this.maxReplyId) {
this.isLoadingMore = false;
this.canLoadMore = false;
return;
}
this.feed.push(...res.data);
this.isLoadingMore = false;
this.maxReplyId = res.data[res.data.length - 1].id;
if(res.data.length > 0) {
this.canLoadMore = true;
} else {
this.canLoadMore = false;
}
}).catch(err => {
this.isLoadingMore = false;
this.canLoadMore = false;
})
},
storeComment() {
axios.post('/api/v0/groups/comment', {
gid: this.groupId,
sid: this.status.id,
content: this.replyContent
})
.then(res => {
this.replyContent = null;
this.feed.unshift(res.data);
}).catch(err => {
if(err.response.status == 422) {
this.isUploading = false;
this.uploadProgress = 0;
swal('Oops!', err.response.data.error, 'error');
} else {
this.isUploading = false;
this.uploadProgress = 0;
swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
}
})
},
shortTimestamp(ts) {
return window.App.util.format.timeAgo(ts);
},
readMore() {
this.readMoreCursor = this.readMoreCursor + 200;
},
likeComment(status, index, $event) {
$event.target.blur();
let l = status.favourited ? false : true;
this.feed[index].favourited = l;
status.favourited = l;
axios.post('/api/v0/groups/like', {
sid: status.id,
gid: this.groupId
});
},
deleteComment(index) {
if(window.confirm('Are you sure you want to delete this post?') == false) {
return;
}
axios.post('/api/v0/groups/status/delete', {
gid: this.groupId,
id: this.feed[index].id
}).then(res => {
this.feed.splice(index, 1);
}).catch(err => {
console.log(err.response);
swal('Error', 'Something went wrong. Please try again later.', 'error');
});
},
uploadImage() {
this.$refs.fileInput.click();
},
handleImageUpload() {
if(!this.$refs.fileInput.files.length) {
return;
}
this.isUploading = true;
let self = this;
let data = new FormData();
data.append('gid', this.groupId);
data.append('sid', this.status.id);
data.append('photo', this.$refs.fileInput.files[0]);
axios.post('/api/v0/groups/comment/photo', data, {
onUploadProgress: function(progressEvent) {
self.uploadProgress = Math.floor(progressEvent.loaded / progressEvent.total * 100);
}
})
.then(res => {
this.isUploading = false;
this.uploadProgress = 0;
this.feed.unshift(res.data);
}).catch(err => {
if(err.response.status == 422) {
this.isUploading = false;
this.uploadProgress = 0;
swal('Oops!', err.response.data.error, 'error');
} else {
this.isUploading = false;
this.uploadProgress = 0;
swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
}
});
},
lightbox(status) {
this.lightboxStatus = status.media_attachments[0];
this.$refs.lightboxModal.show();
},
hideLightbox() {
this.lightboxStatus = null;
this.$refs.lightboxModal.hide();
},
blurhashWidth(status) {
if(!status.media_attachments[0].meta) {
return 25;
}
let aspect = status.media_attachments[0].meta.original.aspect;
if(aspect == 1) {
return 25;
} else if(aspect > 1) {
return 30;
} else {
return 20;
}
},
blurhashHeight(status) {
if(!status.media_attachments[0].meta) {
return 25;
}
let aspect = status.media_attachments[0].meta.original.aspect;
if(aspect == 1) {
return 25;
} else if(aspect > 1) {
return 20;
} else {
return 30;
}
},
getMediaSource(status) {
let media = status.media_attachments[0];
if(media.preview_url.endsWith('storage/no-preview.png')) {
return media.url;
}
return media.preview_url;
},
replyToChild(status, index) {
if(this.replyChildId == status.id) {
this.replyChildId = null;
return;
} else {
this.childReplyContent = null;
}
this.replyChildId = status.id;
if(!status.hasOwnProperty('replies_loaded') || !status.replies_loaded) {
this.fetchChildReplies(status, index);
} else {
}
},
fetchChildReplies(status, index) {
axios.get('/api/v0/groups/comments', {
params: {
gid: this.groupId,
sid: status.id,
cid: 1,
limit: 3
}
}).then(res => {
if(this.feed[index].hasOwnProperty('children')) {
this.feed[index].children.feed.push(...res.data);
this.feed[index].children.can_load_more = res.data.length == 3;
} else {
this.feed[index].children = {
feed: res.data,
can_load_more: res.data.length == 3
}
}
this.feed[index].replies_loaded = true;
}).catch(err => {
})
},
storeChildComment() {
this.postingChildComment = true;
axios.post('/api/v0/groups/comment', {
gid: this.groupId,
sid: this.status.id,
cid: this.replyChildId,
content: this.childReplyContent
})
.then(res => {
this.childReplyContent = null;
this.postingChildComment = false;
// this.feed.unshift(res.data);
}).catch(err => {
if(err.response.status == 422) {
// this.isUploading = false;
// this.uploadProgress = 0;
swal('Oops!', err.response.data.error, 'error');
} else {
// this.isUploading = false;
// this.uploadProgress = 0;
swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
}
});
console.log(this.replyChildId);
}
}
}
</script>
<style lang="scss">
.comment-post-component {
}
</style>

View file

@ -0,0 +1,692 @@
<template>
<div class="context-menu-component 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 && status.account.id != profile.id && ctxMenuRelationship && ctxMenuRelationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-danger" @click="ctxMenuUnfollow()">Unfollow</div>
<div v-if="status && status.account.id != profile.id && ctxMenuRelationship && !ctxMenuRelationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-primary" @click="ctxMenuFollow()">Follow</div> -->
<div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuGoToPost()">View Post</div>
<!-- <div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuGoToProfile()">View Profile</div> -->
<!-- <div v-if="status && status.local == true && !status.in_reply_to_id" class="list-group-item rounded cursor-pointer" @click="ctxMenuEmbed()">Embed</div> -->
<div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">Copy Link</div>
<!-- <div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuShare()">Share</div> -->
<!-- <div v-if="status && profile && profile.is_admin == true && status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxModMenuShow()">Moderation Tools</div> -->
<div v-if="status && status.account.id != profile.id" class="list-group-item rounded cursor-pointer text-danger" @click="ctxMenuReportPost()">Report</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" @click="deletePost(status)">Delete</div>
<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxMenu()">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">Moderation Tools</div>
<div class="small text-center text-muted">Select one of the following options</div>
</p>
<div class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'unlist')">Unlist from Timelines</div>
<div v-if="status.sensitive" class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'remcw')">Remove Content Warning</div>
<div v-else class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'addcw')">Add Content Warning</div>
<div class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'spammer')">
Mark as Spammer<br />
<span class="small">Unlist + CW existing and future posts</span>
</div>
<!-- <div class="list-group-item rounded cursor-pointer" @click="ctxModOtherMenuShow()">Other</div> -->
<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxModMenuClose()">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">Moderation Tools</div>
<div class="small text-center text-muted">Select one of the following options</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()">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="ctxMenuCopyLink()">Copy Link</div>
<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxShareMenu()">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">
Show Caption
</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">
Show Likes
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="ctxEmbedCompactMode">
<label class="form-check-label font-weight-light">
Compact Mode
</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">By using this embed, you agree to our <a href="/site/terms">Terms of Use</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">Report</div>
<div class="small text-center text-muted">Select one of the following options</div>
</p>
<div class="list-group text-center">
<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('spam')">Spam</div>
<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('sensitive')">Sensitive Content</div>
<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('abusive')">Abusive or Harmful</div>
<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="openCtxReportOtherMenu()">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()">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">Report</div>
<div class="small text-center text-muted">Select one of the following options</div>
</p>
<div class="list-group text-center">
<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('underage')">Underage Account</div>
<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('copyright')">Copyright Infringement</div>
<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('impersonation')">Impersonation</div>
<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('scam')">Scam or Fraud</div>
<!-- <div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('terrorism')">Terrorism Related</div> -->
<!-- <div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('other')">Other or Not listed</div> -->
<!-- <div class="list-group-item rounded cursor-pointer" @click="ctxReportOtherMenuGoBack()">Go Back</div> -->
<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxReportOtherMenuGoBack()">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()">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: {
type: Object
},
profile: {
type: Object
},
type: {
type: String,
default: 'status',
validator: (val) => ['status', 'comment', 'profile'].includes(val)
},
groupId: {
type: String
}
},
data() {
return {
ctxMenuStatus: false,
ctxMenuRelationship: false,
ctxEmbedPayload: false,
copiedEmbed: false,
replySending: false,
ctxEmbedShowCaption: true,
ctxEmbedShowLikes: false,
ctxEmbedCompactMode: false,
confirmModalTitle: 'Are you sure?',
confirmModalIdentifer: null,
confirmModalType: false,
}
},
methods: {
open() {
this.ctxMenu();
},
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;
window.location.href = this.statusUrl(status);
this.closeCtxMenu();
return;
},
ctxMenuGoToProfile() {
let status = this.ctxMenuStatus;
window.location.href = this.profileUrl(status);
this.closeCtxMenu();
return;
},
ctxMenuFollow() {
let id = this.ctxMenuStatus.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
let username = this.ctxMenuStatus.account.acct;
this.closeCtxMenu();
setTimeout(function() {
swal('Follow successful!', 'You are now following ' + username, 'success');
}, 500);
});
},
ctxMenuUnfollow() {
let id = this.ctxMenuStatus.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
let username = this.ctxMenuStatus.account.acct;
if(this.scope == 'home') {
this.feed = this.feed.filter(s => {
return s.account.id != this.ctxMenuStatus.account.id;
});
}
this.closeCtxMenu();
setTimeout(function() {
swal('Unfollow successful!', 'You are no longer following ' + username, 'success');
}, 500);
});
},
ctxMenuReportPost() {
this.$refs.ctxModal.hide();
this.$refs.ctxReport.show();
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': 'Confirm Report',
'text': 'Are you sure you want to report this post?',
'icon': 'warning',
'buttons': true,
'dangerMode': true
}).then((res) => {
if(res) {
axios.post(`/api/v0/groups/${this.groupId}/report/create`, {
'type': type,
'id': id,
}).then(res => {
this.closeCtxMenu();
swal('Report Sent!', 'We have successfully received your report.', 'success');
}).catch(err => {
if(err.response.status == 422) {
swal('Oops!', err.response.data.error, 'error');
} else {
swal('Oops!', 'There was an issue reporting this post.', '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('Error', 'Something went wrong. Please try again later.', '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 = 'Are you sure you want to add a content warning to this post?';
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('Success', 'Successfully added content warning', 'success');
status.sensitive = true;
self.closeModals();
self.ctxModMenuClose();
}).catch(err => {
swal(
'Error',
'Something went wrong, please try again later.',
'error'
);
self.closeModals();
self.ctxModMenuClose();
});
}
});
break;
case 'remcw':
msg = 'Are you sure you want to remove the content warning on this post?';
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('Success', 'Successfully added content warning', 'success');
status.sensitive = false;
self.closeModals();
self.ctxModMenuClose();
}).catch(err => {
swal(
'Error',
'Something went wrong, please try again later.',
'error'
);
self.closeModals();
self.ctxModMenuClose();
});
}
});
break;
case 'unlist':
msg = 'Are you sure you want to unlist this post?';
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;
});
swal('Success', 'Successfully unlisted post', 'success');
self.closeModals();
self.ctxModMenuClose();
}).catch(err => {
self.closeModals();
self.ctxModMenuClose();
swal(
'Error',
'Something went wrong, please try again later.',
'error'
);
});
}
});
break;
case 'spammer':
msg = 'Are you sure you want to mark this user as a spammer? All existing and future posts will be unlisted on timelines and a content warning will be applied.';
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('Success', 'Successfully marked account as spammer', 'success');
self.closeModals();
self.ctxModMenuClose();
}).catch(err => {
self.closeModals();
self.ctxModMenuClose();
swal(
'Error',
'Something went wrong, please try again later.',
'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('Error', 'Something went wrong, please try again later.', 'error');
});
},
statusUrl(status) {
if(status.local == true) {
return status.url;
}
return '/i/web/post/_/' + status.account.id + '/' + status.id;
},
profileUrl(status) {
if(status.local == true) {
return status.account.url;
}
return '/i/web/profile/_/' + status.account.id;
},
deletePost(status) {
if($('body').hasClass('loggedIn') == false || this.ownerOrAdmin(status) == false) {
return;
}
if(window.confirm('Are you sure you want to delete this post?') == false) {
return;
}
axios.post('/i/delete', {
type: 'status',
item: status.id
}).then(res => {
this.$emit('status-delete', status.id);
this.closeModals();
}).catch(err => {
swal('Error', 'Something went wrong. Please try again later.', '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('Are you sure you want to archive this post?') == false) {
return;
}
axios.post('/api/pixelfed/v2/status/' + status.id + '/archive')
.then(res => {
this.$emit('status-delete', status.id);
this.closeModals();
});
},
unarchivePost(status) {
if(window.confirm('Are you sure you want to unarchive this post?') == false) {
return;
}
axios.post('/api/pixelfed/v2/status/' + status.id + '/unarchive')
.then(res => {
this.closeModals();
});
}
}
}
</script>

View file

@ -0,0 +1,59 @@
<template>
<div class="form-group row">
<div class="col-sm-3">
<label class="col-form-label text-left">{{ label }}</label>
</div>
<div class="col-sm-9">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="value">
<label
class="form-check-label ml-1"
:class="[ strongText ? 'font-weight-bold text-capitalize text-dark' : 'small text-muted' ]"
>
{{ inputText }}
</label>
</div>
<div
v-if="helpText"
class="help-text small text-muted d-flex flex-row justify-content-between gap-3">
<div v-if="helpText">{{ helpText }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String
},
inputText: {
type: String
},
val: {
type: String
},
helpText: {
type: String
},
strongText: {
type: Boolean,
default: true
}
},
data() {
return {
value: this.val
}
},
watch: {
value: function(newVal, oldVal) {
this.$emit('update', newVal);
}
}
}
</script>

View file

@ -0,0 +1,70 @@
<template>
<div class="form-group row">
<div class="col-sm-3">
<label class="col-form-label text-left">{{ label }}</label>
</div>
<div class="col-sm-9">
<select class="custom-select" v-model="value">
<option value="" selected="" disabled="">{{ placeholder }}</option>
<option v-for="c in categories" :value="c.value">{{ c.key }}</option>
</select>
<div
v-if="helpText || hasLimit"
class="help-text small text-muted d-flex flex-row justify-content-between gap-3">
<div v-if="helpText">{{ helpText }}</div>
<div
v-if="hasLimit"
class="font-weight-bold text-dark">
{{ value ? value.length : 0 }}/{{ maxLimit }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String
},
placeholder: {
type: String
},
categories: {
type: Array
},
val: {
type: String
},
helpText: {
type: String
},
hasLimit: {
type: Boolean,
default: false
},
maxLimit: {
type: Number,
default: 40
},
largeInput: {
type: Boolean,
default: false
}
},
data() {
return {
value: this.val ? this.val : ""
}
},
watch: {
value: function(newVal, oldVal) {
this.$emit('update', newVal);
}
}
}
</script>

View file

@ -0,0 +1,86 @@
<template>
<div class="form-group row">
<div class="col-sm-3">
<label class="col-form-label text-left">{{ label }}</label>
</div>
<div class="col-sm-9">
<textarea
v-if="hasLimit"
type="text"
class="form-control"
:class="{ 'form-control-lg': largeInput }"
style="resize:none;"
:placeholder="placeholder"
:maxlength="maxLimit"
:rows="rows"
v-model="value" />
<textarea
v-else
type="text"
class="form-control"
:class="{ 'form-control-lg': largeInput }"
style="resize:none;"
:placeholder="placeholder"
:rows="rows"
v-model="value" />
<div
v-if="helpText || hasLimit"
class="help-text small text-muted d-flex flex-row justify-content-between gap-3">
<div v-if="helpText">{{ helpText }}</div>
<div
v-if="hasLimit"
class="font-weight-bold text-dark">
{{ value ? value.length : 0 }}/{{ maxLimit }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String
},
placeholder: {
type: String
},
val: {
type: String
},
helpText: {
type: String
},
hasLimit: {
type: Boolean,
default: false
},
maxLimit: {
type: Number,
default: 40
},
largeInput: {
type: Boolean,
default: false
},
rows: {
type: Number,
default: 4
},
},
data() {
return {
value: this.val
}
},
watch: {
value: function(newVal, oldVal) {
this.$emit('update', newVal);
}
}
}
</script>

View file

@ -0,0 +1,78 @@
<template>
<div class="form-group row">
<div class="col-sm-3">
<label class="col-form-label text-left">{{ label }}</label>
</div>
<div class="col-sm-9">
<input
v-if="hasLimit"
type="text"
class="form-control"
:class="{ 'form-control-lg': largeInput }"
:placeholder="placeholder"
:maxlength="maxLimit"
v-model="value">
<input
v-else
type="text"
class="form-control"
:class="{ 'form-control-lg': largeInput }"
:placeholder="placeholder"
v-model="value">
<div
v-if="helpText || hasLimit"
class="help-text small text-muted d-flex flex-row justify-content-between gap-3">
<div v-if="helpText">{{ helpText }}</div>
<div
v-if="hasLimit"
class="font-weight-bold text-dark">
{{ value ? value.length : 0 }}/{{ maxLimit }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String
},
placeholder: {
type: String
},
val: {
type: String
},
helpText: {
type: String
},
hasLimit: {
type: Boolean,
default: false
},
maxLimit: {
type: Number,
default: 40
},
largeInput: {
type: Boolean,
default: false
}
},
data() {
return {
value: this.val
}
},
watch: {
value: function(newVal, oldVal) {
this.$emit('update', newVal);
}
}
}
</script>

View file

@ -0,0 +1,134 @@
<template>
<div class="group-about-component">
<div class="row justify-content-center">
<div class="col-12 col-md-7">
<div class="card shadow-none border mt-3 rounded-lg">
<div class="card-header bg-white">
<h5 class="mb-0">About This Group</h5>
</div>
<div class="card-body">
<p v-if="group.description && group.description.length > 1" class="description" v-html="group.description"></p>
<p v-else class="description">This group does not have a description.</p>
<p class="mb-0 font-weight-light text-lighter">Created: {{ timestampFormat(group.created_at) }}</p>
</div>
</div>
</div>
<div class="col-12 col-md-5">
<div class="card card-body mt-3 shadow-none border rounded-lg">
<div v-if="group.membership == 'all'" class="fact">
<div class="fact-icon">
<i class="fal fa-globe fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title">Public</p>
<p class="fact-subtitle">Anyone can see who's in the group and what they post.</p>
</div>
</div>
<div v-if="group.membership == 'private'" class="fact">
<div class="fact-icon">
<i class="fal fa-lock fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title">Private</p>
<p class="fact-subtitle">Only members can see who's in the group and what they post.</p>
</div>
</div>
<div class="fact">
<div class="fact-icon">
<i class="fal fa-eye fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title">Visible</p>
<p class="fact-subtitle">Anyone can find this group.</p>
</div>
</div>
<div class="fact">
<div class="fact-icon">
<i class="fal fa-map-marker-alt fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title">Fediverse</p>
<p class="fact-subtitle">This group has not specified a location.</p>
</div>
</div>
<div class="fact mb-0">
<div class="fact-icon">
<i class="fal fa-users fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title"">General</p>
<p class="fact-subtitle">This group has not specified a category.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
group: {
type: Object
}
},
methods: {
timestampFormat(date, showTime = false) {
let ts = new Date(date);
return showTime ? ts.toDateString() + ' · ' + ts.toLocaleTimeString() : ts.toDateString();
}
}
}
</script>
<style lang="scss" scoped>
.group-about-component {
margin-bottom: 50vh;
.title {
font-size: 16px;
font-weight: bold;
}
.description {
font-size: 15px;
font-weight:400;
color: #6c757d;
margin-bottom: 30px;
white-space: break-spaces;
}
.fact {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
&-body {
flex: 1;
}
&-icon {
width: 50px;
text-align: center;
}
&-title {
font-size: 17px;
font-weight: 500;
margin-bottom: 0;
}
&-subtitle {
font-size: 14px;
margin-bottom: 0;
color: #6c757d;
}
}
}
</style>

View file

@ -0,0 +1,174 @@
<template>
<div class="col-12 col-md-6 col-xl-4 group-card">
<div class="group-card-inner">
<img
v-if="group.metadata && group.metadata.hasOwnProperty('header')"
:src="group.metadata.header.url"
class="group-header-img" />
<div
v-else
class="group-header-img"
:class="{ compact: compact }">
<div
class="bg-light d-flex align-items-center justify-content-center rounded"
style="width: 100%; height:100%;">
</div>
</div>
<div class="group-card-inner-copy">
<p class="font-weight-bold mb-0 text-dark" style="font-size: 16px;">
{{ truncate(group.name || 'Untitled Group', titleLength) }}
</p>
<p class="text-muted mb-1" style="font-size: 12px;">
{{ truncate(group.short_description, descriptionLength) }}
</p>
<p v-if="showStats" class="mb-0 small text-lighter">
<span>
<i class="fal fa-users"></i>
<span class="small font-weight-bold">{{ prettyCount(group.member_count) }}</span>
</span>
<span v-if="!group.local" class="remote-label ml-3">
<i class="fal fa-globe"></i> Remote
</span>
</p>
</div>
<div class="group-card-inner-foaf">
</div>
<div class="group-card-inner-cta">
<router-link :to="`/groups/${group.id}`" class="btn btn-light btn-block font-weight-bold">
Join Group
</router-link>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
group: {
type: Object
},
compact: {
type: Boolean,
default: false
},
showStats: {
type: Boolean,
default: true
},
truncateTitleLength: {
type: Number,
default: 19
},
truncateDescriptionLength: {
type: Number,
default: 22
}
},
data() {
return {
titleLength: 40,
descriptionLength: 60
}
},
mounted() {
if(this.compact) {
this.titleLength = 19;
this.descriptionLength = 22;
}
if(this.truncateTitleLength != 19) {
this.titleLength = this.truncateTitleLength;
}
if(this.truncateDescriptionLength != 22) {
this.descriptionLength = this.truncateDescriptionLength;
}
},
methods: {
prettyCount(val) {
return App.util.format.count(val);
},
truncate(str, limit = 140) {
if(str.length <= limit) {
return str;
}
return str.substr(0, limit) + ' ...';
}
}
}
</script>
<style lang="scss" scoped>
.group-card {
margin-bottom: 15px;
&-inner {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 10px;
overflow: hidden;
&-copy {
background-color: var(--light);
padding: 1rem;
}
&-foaf {
background-color: var(--light);
height: 30px;
}
&-cta {
background-color: var(--light);
padding: 1rem;
}
}
.member-label {
padding: 2px 5px;
font-size: 9px;
color: rgba(75, 119, 190, 1);
background: rgba(137, 196, 244, 0.2);
border: 1px solid rgba(137, 196, 244, 0.3);
font-weight: 500;
text-transform: capitalize;
border-radius: 3px;
}
.remote-label {
padding: 2px 5px;
font-size: 9px;
color: #B45309;
background: #FEF3C7;
border: 1px solid #FCD34D;
font-weight: 500;
text-transform: capitalize;
border-radius: 3px;
}
.group-header-img {
width: 100%;
height: 150px;
object-fit: cover;
padding: 0px;
overflow: hidden;
}
}
</style>

View file

@ -0,0 +1,345 @@
<template>
<div class="group-compose-form">
<input ref="photoInput" id="photoInput" type="file" class="d-none file-input" accept="image/jpeg,image/png" @change="handlePhotoChange">
<input ref="videoInput" id="videoInput" type="file" class="d-none file-input" accept="video/mp4" @change="handleVideoChange">
<div class="card card-body border mb-3 shadow-sm rounded-lg">
<div class="media align-items-top">
<img v-if="profile" :src="profile.avatar" class="rounded-circle border mr-3" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
<div class="media-body">
<div class="d-block" style="min-height: 80px;">
<div v-if="isUploading" class="w-100">
<p class="font-weight-light mb-1">Uploading media ...</p>
<div class="progress rounded-pill" style="height:4px">
<div class="progress-bar" role="progressbar" :aria-valuenow="uploadProgress" aria-valuemin="0" aria-valuemax="100" :style="{ width: uploadProgress + '%'}"></div>
</div>
</div>
<div v-else class="form-group mb-3">
<textarea
class="form-control"
:class="{
'form-control-lg': !composeText || composeText.length < 40,
'rounded-pill': !composeText || composeText.length < 40,
'bg-light': !composeText || composeText.length < 40,
'border-0': !composeText || composeText.length < 40
}"
:rows="!composeText || composeText.length < 40 ? 1 : 5"
:placeholder="placeholder"
style="resize: none;"
v-model="composeText"
></textarea>
<div v-if="composeText" class="small text-muted mt-1" style="min-height: 20px;">
<span class="float-right font-weight-bold">
{{ composeText ? composeText.length : 0 }}/500
</span>
</div>
</div>
</div>
<div v-if="tab" class="tab">
<div v-if="tab === 'poll'">
<p class="font-weight-bold text-muted small">
Poll Options
</p>
<div v-if="pollOptions.length < 4" class="form-group mb-4">
<input type="text" class="form-control rounded-pill" placeholder="Add a poll option, press enter to save" v-model="pollOptionModel" @keyup.enter="savePollOption">
</div>
<div v-for="(option, index) in pollOptions" class="form-group mb-4 d-flex align-items-center" style="max-width:400px;position: relative;">
<span class="font-weight-bold mr-2" style="position: absolute;left: 10px;">{{ index + 1 }}.</span>
<input v-if="pollOptions[index].length < 50" type="text" class="form-control rounded-pill" placeholder="Add a poll option, press enter to save" v-model="pollOptions[index]" style="padding-left: 30px;padding-right: 90px;">
<textarea v-else class="form-control" v-model="pollOptions[index]" placeholder="Add a poll option, press enter to save" rows="3" style="padding-left: 30px;padding-right:90px;"></textarea>
<button class="btn btn-danger btn-sm rounded-pill font-weight-bold" style="position: absolute;right: 5px;" @click="deletePollOption(index)">
<i class="fas fa-trash"></i> Delete
</button>
</div>
<hr>
<div class="d-flex justify-content-between">
<div>
<p class="font-weight-bold text-muted small">
Poll Expiry
</p>
<div class="form-group">
<select class="form-control rounded-pill" style="width: 200px;" v-model="pollExpiry">
<option value="60">1 hour</option>
<option value="360">6 hours</option>
<option value="1440" selected>24 hours</option>
<option value="10080">7 days</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div v-if="!isUploading" class="">
<div>
<div v-if="photoName && photoName.length" class="bg-light rounded-pill mb-4 py-2">
<div class="media align-items-center">
<span style="width: 40px;height: 40px;border-radius:50px;opacity: 0.6;" class="d-flex align-items-center justify-content-center bg-primary mx-3">
<i class="fal fa-image fa-lg text-white"></i>
</span>
<div class="media-body">
<p class="mb-0 font-weight-bold text-muted">
{{ photoName }}
</p>
</div>
<button class="btn btn-link font-weight-bold text-decoration-none" @click.prevent="clearFileInputs">
Delete
</button>
</div>
</div>
<div v-if="videoName && videoName.length" class="bg-light rounded-pill mb-4 py-2">
<div class="media align-items-center">
<span style="width: 40px;height: 40px;border-radius:50px;opacity: 0.6;" class="d-flex align-items-center justify-content-center bg-primary mx-3">
<i class="fal fa-video fa-lg text-white"></i>
</span>
<div class="media-body">
<p class="mb-0 font-weight-bold text-muted">
{{ videoName }}
</p>
</div>
<button class="btn btn-link font-weight-bold text-decoration-none" @click.prevent="clearFileInputs">
Delete
</button>
</div>
</div>
</div>
<div>
<button
class="btn btn-light border font-weight-bold py-1 px-2 rounded-lg mr-3"
@click="switchTab('photo')"
:disabled="photoName || videoName">
<i class="fal fa-image mr-2"></i>
<span>Add Photo</span>
</button>
<!-- <button
class="btn btn-light border font-weight-bold py-1 px-2 rounded-lg mr-3"
@click="switchTab('video')"
:disabled="photoName || videoName">
<i class="fal fa-video mr-2"></i>
<span>Add Video</span>
</button>
<button
v-if="allowPolls"
:class="[ tab == 'poll' ? 'btn-primary' : 'btn-light' ]"
class="btn border font-weight-bold py-1 px-2 rounded-lg mr-3"
@click="switchTab('poll')"
:disabled="photoName || videoName">
<i class="fal fa-poll-h mr-2"></i>
<span>Add Poll</span>
</button> -->
<!-- <button v-if="allowEvent" class="btn btn-light border font-weight-bold py-1 px-2 rounded-lg">
<i class="fal fa-calendar-alt mr-1"></i>
<span>Create Event</span>
</button> -->
</div>
</div>
</div>
</div>
<p v-if="!isUploading && composeText && composeText.length > 1 || !isUploading && ['photo', 'video'].includes(tab)" class="mb-0">
<button class="btn btn-primary font-weight-bold float-right px-5 rounded-pill mt-3" @click="newPost()" :disabled="isPosting">
<span v-if="isPosting">
<div class="spinner-border text-white spinner-border-sm" role="status">
<span class="sr-only">Loading...</span>
</div>
</span>
<span v-else>Post</span>
</button>
</p>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
profile: {
type: Object
},
groupId: {
type: String
}
},
data() {
return {
config: window.App.config,
composeText: undefined,
tab: null,
placeholder: 'Write something...',
allowPhoto: true,
allowVideo: true,
allowPolls: true,
allowEvent: true,
pollOptionModel: null,
pollOptions: [],
pollExpiry: 1440,
uploadProgress: 0,
isUploading: false,
isPosting: false,
photoName: undefined,
videoName: undefined
}
},
methods: {
newPost() {
if(this.isPosting) {
return;
}
this.isPosting = true;
let self = this;
let type = 'text';
let data = new FormData();
data.append('group_id', this.groupId);
if(this.composeText && this.composeText.length) {
data.append('caption', this.composeText);
}
switch(this.tab) {
case 'poll':
if(!this.pollOptions || this.pollOptions.length < 2 || this.pollOptions.length > 4) {
swal('Oops!', 'A poll must have 2-4 choices.', 'error');
return;
}
if(!this.composeText || this.composeText.length < 5) {
swal('Oops!', 'A poll question must be at least 5 characters.', 'error');
return;
}
for (var i = 0; i < this.pollOptions.length; i++) {
data.append('pollOptions[]', this.pollOptions[i]);
}
data.append('expiry', this.pollExpiry);
type = 'poll';
break;
case 'photo':
data.append('photo', this.$refs.photoInput.files[0]);
type = 'photo';
this.isUploading = true;
break;
case 'video':
data.append('video', this.$refs.videoInput.files[0]);
type = 'video';
this.isUploading = true;
break;
}
data.append('type', type);
axios.post('/api/v0/groups/status/new', data, {
onUploadProgress: function(progressEvent) {
self.uploadProgress = Math.floor(progressEvent.loaded / progressEvent.total * 100);
}
})
.then(res => {
this.isPosting = false;
this.isUploading = false;
this.uploadProgress = 0;
this.composeText = null;
this.photo = null;
this.tab = null;
this.clearFileInputs(false);
this.$emit('new-status', res.data);
}).catch(err => {
if(err.response.status == 422) {
this.isPosting = false;
this.isUploading = false;
this.uploadProgress = 0;
this.photo = null;
this.tab = null;
this.clearFileInputs(false);
swal('Oops!', err.response.data.error, 'error');
} else {
this.isPosting = false;
this.isUploading = false;
this.uploadProgress = 0;
this.photo = null;
this.tab = null;
this.clearFileInputs(false);
swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
}
});
},
switchTab(newTab) {
if(newTab == this.tab) {
this.tab = null;
this.placeholder = 'Write something...';
return;
}
switch(newTab) {
case 'poll':
this.placeholder = 'Write poll question here...'
break;
case 'photo':
this.$refs.photoInput.click();
break;
case 'video':
this.$refs.videoInput.click();
break;
default:
this.placeholder = 'Write something...';
}
this.tab = newTab;
},
savePollOption() {
if(this.pollOptions.indexOf(this.pollOptionModel) != -1) {
this.pollOptionModel = null;
return;
}
this.pollOptions.push(this.pollOptionModel);
this.pollOptionModel = null;
},
deletePollOption(index) {
this.pollOptions.splice(index, 1);
},
handlePhotoChange() {
event.currentTarget.blur();
this.tab = 'photo';
this.photoName = event.target.files[0].name;
},
handleVideoChange() {
event.currentTarget.blur();
this.tab = 'video';
this.videoName = event.target.files[0].name;
},
clearFileInputs(blur = true) {
if(blur) {
event.currentTarget.blur();
}
this.tab = null;
this.$refs.photoInput.value = null;
this.photoName = null;
this.$refs.videoInput.value = null;
this.videoName = null;
}
}
}
</script>
<style lang="scss">
.group-compose-form {
}
</style>

View file

@ -0,0 +1,135 @@
<template>
<div class="group-info-card">
<div class="card card-body mt-3 shadow-none border rounded-lg">
<p class="title">About</p>
<p v-if="group.description && group.description.length > 1" class="description" v-html="group.description"></p>
<p v-else class="description">This group does not have a description.</p>
</div>
<div class="card card-body mt-3 shadow-none border rounded-lg">
<div v-if="group.membership == 'all'" class="fact">
<div class="fact-icon">
<i class="fal fa-globe fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title">Public</p>
<p class="fact-subtitle">Anyone can see who's in the group and what they post.</p>
</div>
</div>
<div v-if="group.membership == 'private'" class="fact">
<div class="fact-icon">
<i class="fal fa-lock fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title">Private</p>
<p class="fact-subtitle">Only members can see who's in the group and what they post.</p>
</div>
</div>
<div v-if="group.config.discoverable == true" class="fact">
<div class="fact-icon">
<i class="fal fa-eye fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title">Visible</p>
<p class="fact-subtitle">Anyone can find this group.</p>
</div>
</div>
<div v-if="group.config.discoverable == false" class="fact">
<div class="fact-icon">
<i class="fal fa-eye-slash fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title">Hidden</p>
<p class="fact-subtitle">Only members can find this group.</p>
</div>
</div>
<!-- <div class="fact">
<div class="fact-icon">
<i class="fal fa-map-marker-alt fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title">Fediverse</p>
<p class="fact-subtitle">This group has not specified a location.</p>
</div>
</div> -->
<div class="fact">
<div class="fact-icon">
<i class="fal fa-users fa-lg"></i>
</div>
<div class="fact-body">
<p class="fact-title"">{{ group.category.name }}</p>
<p class="fact-subtitle">Category</p>
</div>
</div>
<p class="mb-0 font-weight-light text-lighter">Created: {{ timestampFormat(group.created_at) }}</p>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
group: {
type: Object
}
},
methods: {
timestampFormat(date, showTime = false) {
let ts = new Date(date);
return showTime ? ts.toDateString() + ' · ' + ts.toLocaleTimeString() : ts.toDateString();
}
}
}
</script>
<style lang="scss" scoped>
.group-info-card {
.title {
font-size: 16px;
font-weight: bold;
}
.description {
font-size: 15px;
font-weight:400;
color: #6c757d;
margin-bottom: 0;
white-space: break-spaces;
}
.fact {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
&-body {
flex: 1;
}
&-icon {
width: 50px;
text-align: center;
}
&-title {
font-size: 17px;
font-weight: 500;
margin-bottom: 0;
}
&-subtitle {
font-size: 14px;
margin-bottom: 0;
color: #6c757d;
}
}
}
</style>

View file

@ -0,0 +1,60 @@
<template>
<div class="group-insights-component">
<div class="row justify-content-center">
<div class="col-12 col-md-3 mb-3">
<div class="bg-light p-3 border rounded d-flex justify-content-between align-items-center">
<p class="h3 font-weight-bold mb-0">124K</p>
<p class="font-weight-bold text-uppercase text-lighter mb-0">Posts</p>
</div>
</div>
<div class="col-12 col-md-3 mb-3">
<div class="bg-light p-3 border rounded d-flex justify-content-between align-items-center">
<p class="h3 font-weight-bold mb-0">9K</p>
<p class="font-weight-bold text-uppercase text-lighter mb-0">Users</p>
</div>
</div>
<div class="col-12 col-md-3 mb-3">
<div class="bg-light p-3 border rounded d-flex justify-content-between align-items-center">
<p class="h3 font-weight-bold mb-0">1.7M</p>
<p class="font-weight-bold text-uppercase text-lighter mb-0">Interactions</p>
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col-12 col-md-3 mb-3">
<div class="bg-light p-3 border rounded d-flex justify-content-between align-items-center">
<p class="h3 font-weight-bold mb-0">50</p>
<p class="font-weight-bold text-uppercase text-lighter mb-0">Mod Reports</p>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
group: {
type: Object
}
},
data() {
return {
};
},
methods: {
}
}
</script>
<style lang="scss" scoped>
.group-insights-component {
}
</style>

View file

@ -0,0 +1,190 @@
<template>
<div class="group-invite-modal">
<b-modal
ref="modal"
hide-header
hide-footer
centered
rounded
body-class="rounded group-invite-modal-wrapper">
<div class="text-center py-3 d-flex align-items-center flex-column">
<div class="bg-light rounded-circle d-flex justify-content-center align-items-center mb-3" style="width: 100px;height: 100px;">
<i class="far fa-user-plus fa-2x text-lighter"></i>
</div>
<p class="h4 font-weight-bold mb-0">Invite Friends</p>
<!-- <p class="mb-0">Search {{ group.name }} for posts, comments or members.</p> -->
</div>
<transition name="fade">
<div v-if="usernames.length < 5" class="d-flex justify-content-between mt-1">
<autocomplete
:search="autocompleteSearch"
placeholder="Search friends by username"
aria-label="Search this group"
:get-result-value="getSearchResultValue"
:debounceTime="700"
@submit="onSearchSubmit"
style="width: 100%;"
ref="autocomplete"
>
<template #result="{ result, props }">
<li
v-bind="props"
class="autocomplete-result"
>
<div class="text-truncate">
<p class="result-name mb-0 font-weight-bold">
{{ result.username }}
</p>
</div>
</li>
</template>
</autocomplete>
<button class="btn btn-light border rounded-circle text-lighter ml-3" style="width: 52px;height:50px;" @click="close">
<i class="fal fa-times fa-lg"></i>
</button>
</div>
</transition>
<transition name="fade">
<div v-if="usernames.length" class="pt-3">
<div v-for="(result, index) in usernames" class="py-1">
<div class="media align-items-center">
<img src="/storage/avatars/default.jpg" class="rounded-circle border mr-3" width="45" height="45">
<div class="media-body">
<p class="lead mb-0">{{ result.username }}</p>
</div>
<button class="btn btn-link text-lighter btn-sm" @click="removeUsername(index)"><i class="far fa-times-circle fa-lg"></i></button>
</div>
</div>
</div>
</transition>
<transition name="fade">
<button v-if="usernames && usernames.length" class="btn btn-primary btn-lg btn-block font-weight-bold rounded font-weight-bold mt-3" @click="submitInvites">Invite</button>
</transition>
<div class="text-center pt-3 small">
<p class="mb-0">You can invite up to 5 friends at a time, and 20 friends in total.</p>
</div>
</b-modal>
</div>
</template>
<script type="text/javascript">
import Autocomplete from '@trevoreyre/autocomplete-vue'
import '@trevoreyre/autocomplete-vue/dist/style.css'
export default {
props: {
group: {
type: Object
},
profile: {
type: Object
}
},
components: {
'autocomplete-input': Autocomplete,
},
data() {
return {
query: '',
recent: [],
loaded: false,
usernames: [],
isSubmitting: false
}
},
methods: {
open() {
this.$refs.modal.show();
},
close() {
this.$refs.modal.hide();
},
autocompleteSearch(q) {
if (!q || q.length == 0) {
return [];
}
return axios.post(`/api/v0/groups/search/invite/friends`, {
q: q,
g: this.group.id,
v: '0.2'
}).then(res => {
let data = res.data.filter(r => {
return this.usernames.map(u => u.username).indexOf(r.username) == -1;
});
return data;
});
return [];
},
getSearchResultValue(result) {
return result.username;
},
onSearchSubmit(result) {
this.usernames.push(result);
this.$refs.autocomplete.value = '';
//
},
removeUsername(index) {
event.currentTarget.blur();
this.usernames.splice(index, 1);
},
submitInvites() {
this.isSubmitting = true;
event.currentTarget.blur();
axios.post('/api/v0/groups/search/invite/friends/send', {
g: this.group.id,
uids: this.usernames.map(u => u.id)
}).then(res => {
this.usernames = [];
this.isSubmitting = false;
this.close();
swal('Success', 'Successfully sent invite(s)', 'success');
}).catch(err => {
this.usernames = [];
this.isSubmitting = false;
if(err.response.status === 422) {
swal('Error', err.response.data.error, 'error');
} else {
swal('Oops!', 'An error occured, please try again later', 'error');
}
this.close();
})
}
}
}
</script>
<style lang="scss">
.group-invite-modal {
&-wrapper {
.media {
height: 60px;
padding: 10px;
border-radius: 10px;
user-select: none;
cursor: pointer;
&:hover {
background-color: #E5E7EB;
}
}
}
}
</style>

View file

@ -0,0 +1,156 @@
<template>
<div class="group-list-card">
<div class="media">
<div class="media align-items-center">
<img
v-if="group.metadata && group.metadata.hasOwnProperty('header')"
:src="group.metadata.header.url"
class="mr-3 border rounded group-header-img"
:class="{ compact: compact }">
<div
v-else
class="mr-3 border rounded group-header-img"
:class="{ compact: compact }">
<div
class="bg-primary d-flex align-items-center justify-content-center rounded"
style="width: 100%; height:100%;">
<i class="fal fa-users text-white fa-lg"></i>
</div>
</div>
<div class="media-body">
<p class="font-weight-bold mb-0 text-dark" style="font-size: 16px;">
{{ truncate(group.name || 'Untitled Group', titleLength) }}
</p>
<p class="text-muted mb-1" style="font-size: 12px;">
{{ truncate(group.short_description, descriptionLength) }}
</p>
<p v-if="showStats" class="mb-0 small text-lighter">
<span>
<i class="far fa-users"></i>
<span class="small font-weight-bold">{{ prettyCount(group.member_count) }}</span>
</span>
<span v-if="!group.local" class="remote-label ml-3">
<i class="fal fa-globe"></i> Remote
</span>
<span v-if="group.hasOwnProperty('admin') && group.admin.hasOwnProperty('username')" class="ml-3">
<i class="fal fa-user-crown"></i>
<span class="small font-weight-bold">
&commat;{{ group.admin.username }}
</span>
</span>
</p>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
group: {
type: Object
},
compact: {
type: Boolean,
default: false
},
showStats: {
type: Boolean,
default: false
},
truncateTitleLength: {
type: Number,
default: 19
},
truncateDescriptionLength: {
type: Number,
default: 22
}
},
data() {
return {
titleLength: 40,
descriptionLength: 60
}
},
mounted() {
if(this.compact) {
this.titleLength = 19;
this.descriptionLength = 22;
}
if(this.truncateTitleLength != 19) {
this.titleLength = this.truncateTitleLength;
}
if(this.truncateDescriptionLength != 22) {
this.descriptionLength = this.truncateDescriptionLength;
}
},
methods: {
prettyCount(val) {
return App.util.format.count(val);
},
truncate(str, limit = 140) {
if(str.length <= limit) {
return str;
}
return str.substr(0, limit) + ' ...';
}
}
}
</script>
<style lang="scss" scoped>
.group-list-card {
.member-label {
padding: 2px 5px;
font-size: 9px;
color: rgba(75, 119, 190, 1);
background: rgba(137, 196, 244, 0.2);
border: 1px solid rgba(137, 196, 244, 0.3);
font-weight: 500;
text-transform: capitalize;
border-radius: 3px;
}
.remote-label {
padding: 2px 5px;
font-size: 9px;
color: #B45309;
background: #FEF3C7;
border: 1px solid #FCD34D;
font-weight: 500;
text-transform: capitalize;
border-radius: 3px;
}
.group-header-img {
width: 60px;
height: 60px;
object-fit: cover;
padding:0px;
&.compact {
width: 42.5px;
height: 42.5px;
}
}
}
</style>

View file

@ -0,0 +1,262 @@
<template>
<div class="group-media-component">
<div class="row justify-content-center">
<div class="col-12">
<div class="card card-body border shadow-sm">
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="h4 font-weight-bold mb-0">Media</p>
<!-- <div>
<a href="#" class="font-weight-bold mr-3"><i class="fas fa-plus fa-sm"></i> Create Album</a>
<a href="#" class="font-weight-bold">Add Photos/Video</a>
</div> -->
</div>
<div v-if="isLoaded">
<div class="mb-5">
<button
:class="[ tab == 'photo' ? 'text-primary font-weight-bold' : 'text-lighter' ]"
class="btn btn-light mr-2"
@click="switchTab('photo')">
Photos
</button>
<button
:class="[ tab == 'video' ? 'text-primary font-weight-bold' : 'text-lighter' ]"
class="btn btn-light mr-2"
@click="switchTab('video')">
Videos
</button>
<button
:class="[ tab == 'album' ? 'text-primary font-weight-bold' : 'text-lighter' ]"
class="btn btn-light mr-2"
@click="switchTab('album')">
Albums
</button>
</div>
<div v-if="tab == 'photo'" class="row px-3">
<div v-for="(status, index) in photos" class="m-1">
<a :href="status.url" class="bh-content">
<img :src="getMediaSource(status)" width="205" height="205" style="object-fit: cover;">
</a>
</div>
<div v-if="photos.length == 0" class="col-12 py-5 text-center">
<p class="lead font-weight-bold mb-0">No photos found</p>
</div>
</div>
<div v-if="tab == 'video'" class="row px-3">
<div v-for="(status, index) in videos" class="m-1">
<a :href="status.url" class="bh-content text-decoration-none">
<img v-if="!status.media_attachments[0].preview_url.endsWith('no-preview.png')" :src="getMediaSource(status)" width="205" height="205" style="object-fit: cover;">
<div v-else class="bg-light text-dark d-flex align-items-center justify-content-center border" style="width:205px;height:205px;">
<p class="font-weight-bold mb-0">No preview available</p>
</div>
</a>
</div>
<div v-if="videos.length == 0" class="col-12 py-5 text-center">
<p class="lead font-weight-bold mb-0">No videos found</p>
</div>
</div>
<div v-if="tab == 'album'" class="row px-3">
<div v-for="(status, index) in albums" class="m-1">
<a :href="status.url" class="bh-content">
<img :src="getMediaSource(status)" width="205" height="205" style="object-fit: cover;">
</a>
</div>
<div v-if="albums.length == 0" class="col-12 py-5 text-center">
<p class="lead font-weight-bold mb-0">No albums found</p>
</div>
</div>
<div v-if="hasNextPage[tab]" class="mt-3">
<button class="btn btn-light font-weight-bold btn-block border" @click="loadNextPage">Load more</button>
</div>
</div>
<div v-else class="d-flex align-items-center justify-content-center" style="height:500px">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
group: {
type: Object
}
},
data() {
return {
isLoaded: false,
feed: [],
photos: [],
videos: [],
albums: [],
tab: 'photo',
tabs: [
'photo',
'video',
'album'
],
page: {
'photo': 1,
'video': 1,
'album': 1
},
hasNextPage: {
'photo': false,
'video': false,
'album': false
}
}
},
mounted() {
this.fetchMedia();
},
methods: {
fetchMedia() {
axios.get('/api/v0/groups/media/list', {
params: {
gid: this.group.id,
page: this.page[this.tab],
type: this.tab
}
}).then(res => {
if(res.data.length > 0) {
this.hasNextPage[this.tab] = true;
}
this.isLoaded = true;
res.data.forEach(status => {
if(status.pf_type == 'photo') {
this.photos.push(status);
}
if(status.pf_type == 'video') {
this.videos.push(status);
}
if(status.pf_type == 'photo:album') {
this.albums.push(status);
}
})
this.page[this.tab] = this.page[this.tab] + 1;
}).catch(err => {
this.hasNextPage[this.tab] = false;
console.log(err.response);
})
},
loadNextPage() {
axios.get('/api/v0/groups/media/list', {
params: {
gid: this.group.id,
page: this.page[this.tab],
type: this.tab,
}
}).then(res => {
if(res.data.length == 0) {
this.hasNextPage[this.tab] = false;
return;
}
res.data.forEach(status => {
if(status.pf_type == 'photo') {
this.photos.push(status);
}
if(status.pf_type == 'video') {
this.videos.push(status);
}
if(status.pf_type == 'photo:album') {
this.albums.push(status);
}
})
this.page[this.tab] = this.page[this.tab] + 1;
}).catch(err => {
this.hasNextPage[this.tab] = false;
})
},
formatDate(ts) {
return new Date(ts).toDateString();
},
switchTab(tab) {
this.tab = tab;
this.fetchMedia();
},
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 && media.preview_url.endsWith('storage/no-preview.png')) {
return media.url;
}
if(media.preview_url && media.preview_url.length) {
return media.url;
}
return media.url;
}
}
}
</script>
<style lang="scss">
.group-members-component {
}
</style>

View file

@ -0,0 +1,684 @@
<template>
<div class="group-members-component">
<div class="row justify-content-center">
<div class="col-12 col-md-8 mb-5">
<div v-if="isAdmin && requestCount && !hideHeader" class="card card-body border shadow-sm bg-dark text-light mb-4 rounded-pill p-2 pl-3">
<div class="d-flex align-items-center justify-content-between">
<span class="lead mb-0 text-lighter">
<i class="fal fa-exclamation-triangle mr-2 text-warning"></i>
You have <strong class="text-white">{{ requestCount }}</strong> member applications to review
</span>
<span>
<button class="btn btn-primary font-weight-bold rounded-pill btn-sm px-3" @click="reviewApplicants()">Review</button>
</span>
</div>
</div>
<div class="card card-body border shadow-sm">
<div v-if="!hideHeader">
<p class="d-flex align-items-center mb-0">
<span class="lead font-weight-bold">Members</span>
<span class="mx-2">·</span>
<span class="text-muted">{{group.member_count}}</span>
</p>
<!-- <p class="text-muted mb-0">
New people who join this group will appear here. <a class="font-weight-bold text-dark" href="#">Learn More</a>
</p> -->
<div class="form-group mt-3" style="position: relative;">
<i class="fas fa-search fa-lg text-lighter" style="position: absolute;left:20px; top:50%;transform:translateY(-50%);"></i>
<input class="form-control form-control-lg bg-light border rounded-pill" style="padding-left: 50px;padding-right: 50px;" placeholder="Find a member" v-model="memberSearchModel">
<button class="btn btn-primary font-weight-bold rounded-pill px-3" style="position: absolute;right: 6px; top: 50%;transform: translateY(-50%);">Search</button>
</div>
<hr>
</div>
<div v-if="tab == 'list'">
<div class="group-members-component-paginated-list py-3">
<div class="media align-items-center">
<a :href="profile.url" class="text-dark text-decoration-none">
<img
:src="profile?.avatar"
width="56"
height="56"
class="rounded-circle border mr-2"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
/>
</a>
<div class="media-body">
<p class="lead font-weight-bold mb-0">
{{profile.username}}
<span class="member-label rounded ml-1">Me</span>
</p>
<p class="text-muted mb-0">Founded group {{formatDate(group.created_at)}}</p>
</div>
<!-- <button class="btn btn-light border">
<i class="fas fa-ellipsis-h text-muted"></i>
</button> -->
</div>
</div>
<hr v-if="mutual.length > 0">
<div v-if="mutual.length > 0" class="group-members-component-paginated-list">
<p class="font-weight-bold mb-1">Mutual Friends</p>
<div v-for="(member, index) in mutual" class="media align-items-center py-3">
<a :href="member.url" class="text-dark text-decoration-none">
<img
:src="member?.avatar"
width="56"
height="56"
class="rounded-circle border mr-2"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
/>
</a>
<div class="media-body">
<p class="lead font-weight-bold mb-0">
<a :href="member.url" class="text-dark text-decoration-none" :title="member.acct" data-toggle="tooltip">{{member.username}}</a>
<span v-if="member.role == 'founder'" class="member-label rounded ml-1">Admin</span>
<span v-if="!member.local" class="remote-label rounded ml-1">Remote</span>
</p>
<p class="text-muted mb-0">Member since {{formatDate(member.joined)}}</p>
</div>
<a
class="btn btn-light lead font-weight-bolder px-3 border"
:href="'/account/direct/t/' + member.id">
<i class="far fa-comment-dots mr-1"></i> Message
</a>
<b-dropdown
v-if="isAdmin"
toggle-class="btn btn-light font-weight-bold px-3 border ml-2"
toggle-tag="a"
:lazy="true"
right
no-caret>
<template #button-content>
<i class="fas fa-ellipsis-h"></i>
</template>
<b-dropdown-item :href="member.url">View Profile</b-dropdown-item>
<b-dropdown-item :href="'/account/direct/t/'+member.id">Send Message</b-dropdown-item>
<b-dropdown-item>View Activity</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item link-class="font-weight-bold" @click.prevent="openInteractionLimitModal(member)">Limit interactions</b-dropdown-item>
<b-dropdown-item link-class="font-weight-bold text-danger">Remove from group</b-dropdown-item>
</b-dropdown>
</div>
</div>
<hr v-if="members.length > 0">
<div v-if="members.length > 0" class="group-members-component-paginated-list">
<p class="font-weight-bold mb-1">Other Members</p>
<div v-for="(member, index) in members" class="media align-items-center py-3">
<a :href="member.url" class="text-dark text-decoration-none">
<img
:src="member?.avatar"
width="56"
height="56"
class="rounded-circle border mr-2"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
/>
</a>
<div class="media-body">
<p class="lead font-weight-bold mb-0">
<a :href="member.url" class="text-dark text-decoration-none" :title="member.acct" data-toggle="tooltip">{{member.username}}</a>
<span v-if="member.is_admin" class="member-label rounded ml-1">Admin</span>
<span v-if="!member.local" class="remote-label rounded ml-1">Remote</span>
</p>
<p class="text-muted mb-0">Member since {{formatDate(member.joined)}}</p>
</div>
<button
:class="[ member.following ? 'btn-primary' : 'btn-light']"
class="btn lead font-weight-bolder px-4 border"
@click="follow(index)">
<span v-if="member.following">
Following
</span>
<span v-else>
<i class="fas fa-user-plus mr-2"></i> Follow
</span>
</button>
<b-dropdown
v-if="isAdmin"
toggle-class="btn btn-light font-weight-bold px-3 border ml-2"
toggle-tag="a"
:lazy="true"
right
no-caret>
<template #button-content>
<i class="fas fa-ellipsis-h"></i>
</template>
<b-dropdown-item :href="member.url" link-class="font-weight-bold">View Profile</b-dropdown-item>
<b-dropdown-item :href="'/account/direct/t/'+member.id" link-class="font-weight-bold">Send Message</b-dropdown-item>
<b-dropdown-item link-class="font-weight-bold">View Activity</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item link-class="font-weight-bold" @click.prevent="openInteractionLimitModal(member)">Limit interactions</b-dropdown-item>
<b-dropdown-item link-class="font-weight-bold text-danger">Remove from group</b-dropdown-item>
</b-dropdown>
</div>
</div>
<p v-if="members.length > 0 && hasNextPage" class="mt-4">
<button class="btn btn-light btn-block border font-weight-bold" @click="loadNextPage">Load more</button>
</p>
</div>
<div v-if="tab == 'search'" class="d-flex justify-content-center align-items-center" style="min-height: 100px;">
<div class="text-center text-muted">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
<p class="lead mb-0 mt-2">Loading results ...</p>
</div>
</div>
<div v-if="tab == 'results'">
<div v-if="results.length > 0" class="group-members-component-paginated-list">
<p class="font-weight-bold mb-1">Results</p>
<div v-for="(member, index) in results" class="media align-items-center py-3">
<a :href="member.url" class="text-dark text-decoration-none">
<img
:src="member?.avatar"
width="56"
height="56"
class="rounded-circle border mr-2"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
/>
</a>
<div class="media-body">
<p class="lead font-weight-bold mb-0">
<a :href="member.url" class="text-dark text-decoration-none" :title="member.acct" data-toggle="tooltip">{{member.username}}</a>
<span v-if="member.role == 'founder'" class="member-label rounded ml-1">Admin</span>
<span v-if="!member.local" class="remote-label rounded ml-1">Remote</span>
</p>
<p class="text-muted mb-0">Member since {{formatDate(member.joined)}}</p>
</div>
<a
class="btn btn-light lead font-weight-bolder px-3 border"
:href="'/account/direct/t/' + member.id">
<i class="far fa-comment-dots mr-1"></i> Message
</a>
<b-dropdown
v-if="isAdmin"
toggle-class="btn btn-light font-weight-bold px-3 border ml-2"
toggle-tag="a"
:lazy="true"
right
no-caret>
<template #button-content>
<i class="fas fa-ellipsis-h"></i>
</template>
<b-dropdown-item :href="member.url">View Profile</b-dropdown-item>
<b-dropdown-item :href="'/account/direct/t/'+member.id">Send Message</b-dropdown-item>
<b-dropdown-item>View Activity</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item link-class="font-weight-bold" @click.prevent="openInteractionLimitModal(member)">Limit interactions</b-dropdown-item>
<b-dropdown-item link-class="font-weight-bold text-danger">Remove from group</b-dropdown-item>
</b-dropdown>
</div>
<p class="text-center mt-5">
<a href="#" class="font-weight-bold" @click="backFromSearch">Go back</a>
</p>
</div>
<div v-else class="text-center text-muted mt-3">
<p class="lead">No results found.</p>
<p>
<a href="#" class="font-weight-bold" @click="backFromSearch">Go back</a>
</p>
</div>
</div>
<div v-if="tab == 'memberInteractionLimits'">
<div v-if="results.length > 0" class="group-members-component-paginated-list">
<p class="font-weight-bold mb-1">Interaction Limits</p>
<div v-for="(member, index) in results" class="media align-items-center py-3">
<a :href="member.url" class="text-dark text-decoration-none">
<img
:src="member?.avatar"
width="56"
height="56"
class="rounded-circle border mr-2"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
/>
</a>
<div class="media-body">
<p class="lead font-weight-bold mb-0">
<a :href="member.url" class="text-dark text-decoration-none">{{member.username}}</a>
<span v-if="member.role == 'founder'" class="member-label rounded ml-1">Admin</span>
</p>
<p class="text-muted mb-0">Member since {{formatDate(member.joined)}}</p>
</div>
<a
class="btn btn-light lead font-weight-bolder px-3 border"
:href="'/account/direct/t/' + member.id">
<i class="far fa-comment-dots mr-1"></i> Message
</a>
<b-dropdown
v-if="isAdmin"
toggle-class="btn btn-light font-weight-bold px-3 border ml-2"
toggle-tag="a"
:lazy="true"
right
no-caret>
<template #button-content>
<i class="fas fa-ellipsis-h"></i>
</template>
<b-dropdown-item :href="member.url">View Profile</b-dropdown-item>
<b-dropdown-item :href="'/account/direct/t/'+member.id">Send Message</b-dropdown-item>
<b-dropdown-item>View Activity</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item link-class="font-weight-bold" @click.prevent="openInteractionLimitModal(member)">Limit interactions</b-dropdown-item>
<b-dropdown-item link-class="font-weight-bold text-danger">Remove from group</b-dropdown-item>
</b-dropdown>
</div>
<p class="text-center mt-5">
<a href="#" class="font-weight-bold" @click.prevent="backFromSearch">Go back</a>
</p>
</div>
<div v-else class="text-center text-muted mt-3">
<p class="lead">No results found.</p>
<p>
<a href="#" class="font-weight-bold" @click.prevent="backFromSearch">Go back</a>
</p>
</div>
</div>
<div v-if="tab == 'review'">
<div v-if="reviewsLoaded">
<div class="group-members-component-paginated-list">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex">
<button class="btn btn-link btn-sm mr-2" @click="backFromReview">
<i class="far fa-chevron-left fa-lg"></i>
</button>
<p class="font-weight-bold mb-0">Review Membership Applicants</p>
</div>
</div>
<hr>
<div v-for="(member, index) in applicants" class="media align-items-center py-3">
<a :href="member.url" class="text-dark text-decoration-none">
<img
:src="member?.avatar"
width="56"
height="56"
class="rounded-circle border mr-2"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
/>
</a>
<div class="media-body">
<p class="lead font-weight-bold mb-0">
<a :href="member.url" class="text-dark text-decoration-none" :title="member.acct" data-toggle="tooltip">{{member.username}}</a>
<span v-if="!member.local" class="remote-label rounded ml-1">Remote</span>
</p>
<p class="text-muted mb-0 small">
<span>
{{ member.followers_count }} Followers
</span>
<span class="mx-1">·</span>
<span>
Joined {{formatDate(member.created_at)}}
</span>
</p>
</div>
<button
type="button"
class="btn btn-light lead font-weight-bolder px-3 border"
@click="handleApplicant(index, 'ignore')">
<i class="far fa-times mr-1"></i> Ignore
</button>
<button
type="button"
class="btn btn-danger lead font-weight-bolder px-3 border"
@click="handleApplicant(index, 'reject')">
<i class="far fa-times mr-1"></i> Reject
</button>
<button
type="button"
class="btn btn-primary lead font-weight-bolder px-3 border"
@click="handleApplicant(index, 'approve')">
<i class="far fa-check mr-1"></i> Approve
</button>
</div>
<button
v-if="applicantsCanLoadMore"
class="btn btn-light font-weight-bold btn-block"
@click="loadMoreApplicants"
:disabled="loadingMoreApplicants">
Load More
</button>
<div v-if="!applicants || !applicants.length">
<p class="text-center lead mb-0">No content found</p>
<p class="text-center mb-0">
<a class="font-weight-bold" href="#" @click.prevent="backFromReview">Go back</a>
</p>
</div>
</div>
</div>
<div v-else class="d-flex align-items-center justify-content-center" style="min-height: 100px;">
<b-spinner variant="muted" />
</div>
</div>
<div v-if="tab == 'loading'" class="d-flex align-items-center justify-content-center" style="min-height: 100px;">
<b-spinner variant="muted" />
</div>
</div>
</div>
</div>
<group-interaction-limits-modal
ref="interactionModal"
:group="group"
:profile="activeProfile"
/>
</div>
</template>
<script type="text/javascript">
import InteractionModal from './MemberLimitInteractionsModal.vue';
export default {
props: {
group: {
type: Object
},
profile: {
type: Object
},
requestCount: {
type: Number
},
isAdmin: {
type: Boolean
}
},
components: {
'group-interaction-limits-modal': InteractionModal
},
data() {
return {
members: [],
mutual: [],
results: [],
page: 1,
hasNextPage: true,
tab: 'list',
memberSearchModel: null,
activeProfile: undefined,
hideHeader: false,
reviewsLoaded: false,
applicants: [],
applicantsPage: 1,
applicantsCanLoadMore: false,
loadingMoreApplicants: false
}
},
mounted() {
this.fetchMembers();
let u = new URLSearchParams(window.location.search);
if(this.group.self.role == 'founder') {
this.isAdmin = true;
if(u.has('a')) {
if(u.get('a') == 'il') {
this.tab = 'loading';
let pid = u.get('pid');
axios.get('/api/v0/groups/members/get', {
params: {
gid: this.group.id,
pid: pid
}
})
.then(res => {
this.results.push(res.data);
this.tab = 'memberInteractionLimits';
this.openInteractionLimitModal(res.data);
});
}
if(u.get('a') == 'review') {
this.reviewApplicants();
}
}
} else if (u.has('a')) {
history.pushState(null, null, `/groups/${this.group.id}/members`);
}
},
watch: {
memberSearchModel: function(val) {
this.handleSearch();
}
},
methods: {
fetchMembers() {
axios.get('/api/v0/groups/members/list', {
params: {
gid: this.group.id,
page: this.page
}
}).then(res => {
let data = res.data.filter(m => {
return m.id != this.profile.id;
});
this.members = data.filter(m => {
return !m.following
});
this.mutual = data.filter(m => {
return m.following
});
this.page++;
this.$nextTick(() => {
$('[data-toggle="tooltip"]').tooltip()
});
}).catch(err => {
console.log(res.response);
})
},
loadNextPage() {
axios.get('/api/v0/groups/members/list', {
params: {
gid: this.group.id,
page: this.page
}
}).then(res => {
if(res.data.length == 0) {
this.hasNextPage = false;
return;
}
let data = res.data.filter(m => {
return m.id != this.profile.id;
});
this.members.push(...data.filter(m => {
return !m.following
}));
this.mutual.push(...data.filter(m => {
return m.following
}));
this.page++;
this.$nextTick(() => {
$('[data-toggle="tooltip"]').tooltip()
});
}).catch(err => {
console.log(err.response);
})
},
follow(index) {
axios.post('/i/follow', {
item: this.members[index].id
}).then(res => {
this.members[index].following = !this.members[index].following;
}).catch(err => {
console.log(err.response);
})
},
formatDate(ts) {
return new Date(ts).toDateString();
},
handleSearch() {
if(!this.memberSearchModel || this.memberSearchModel == "" || this.memberSearchModel.length == 0) {
this.tab == 'list';
this.memberSearchModel = null;
return;
}
this.tab = 'search';
this.results = this.members.concat(this.mutual).filter(profile => {
return profile.username.includes(this.memberSearchModel);
});
// if(this.results.length) {
this.tab = 'results';
// }
},
backFromSearch() {
event.currentTarget.blur();
this.memberSearchModel = null;
this.tab = 'list';
history.pushState(null, null, `/groups/${this.group.id}/members`);
},
openInteractionLimitModal(member) {
this.activeProfile = member;
setTimeout(() => {
this.$refs.interactionModal.open();
}, 500);
},
reviewApplicants() {
this.hideHeader = true;
this.tab = 'review';
history.pushState(null, null, `/groups/${this.group.id}/members?a=review`);
axios.get('/api/v0/groups/members/requests', {
params: {
gid: this.group.id
}
})
.then(res => {
this.applicants = res.data;
this.reviewsLoaded = true;
this.applicantsPage = 2;
this.applicantsCanLoadMore = res.data.length == 10;
})
},
handleApplicant(index, action) {
event.currentTarget.blur();
if(action == 'ignore') {
this.applicants.splice(index, 1);
return;
}
this.tab = 'loading';
if(!window.confirm('Are you sure you want to perform this action?')) {
return;
}
axios.post('/api/v0/groups/members/request', {
gid: this.group.id,
pid: this.applicants[index].id,
action: action
})
.then(res => {
this.applicants.splice(index, 1);
this.tab = 'review';
this.$emit('decrementrc');
if(action == 'approve') {
this.$emit('incrementMemberCount');
}
})
},
backFromReview() {
event.currentTarget.blur();
this.memberSearchModel = null;
this.tab = 'list';
this.hideHeader = false;
history.pushState(null, null, `/groups/${this.group.id}/members`);
},
loadMoreApplicants() {
this.loadingMoreApplicants = true;
axios.get('/api/v0/groups/members/requests', {
params: {
gid: this.group.id,
page: this.applicantsPage
}
})
.then(res => {
this.applicants.push(...res.data);
this.applicantsCanLoadMore = res.data.length == 10;
this.applicantsPage++;
this.loadingMoreApplicants = false;
})
}
}
}
</script>
<style lang="scss">
.group-members-component {
min-height: 100vh;
.member-label {
padding: 2px 5px;
font-size: 12px;
color: rgba(75, 119, 190, 1);
background: rgba(137, 196, 244, 0.2);
border: 1px solid rgba(137, 196, 244, 0.3);
font-weight: 400;
text-transform: capitalize;
}
.remote-label {
padding: 2px 5px;
font-size: 12px;
color: #B45309;
background: #FEF3C7;
border: 1px solid #FCD34D;
font-weight: 400;
text-transform: capitalize;
}
}
</style>

View file

@ -0,0 +1,231 @@
<template>
<div class="group-moderation-component">
<div v-if="initalLoad">
<div v-if="tab === 'home'">
<div class="row justify-content-center">
<div class="col-12 col-md-6 pt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="lead mb-0">Latest Mod Reports</p>
<button class="btn btn-light border font-weight-bold btn-sm rounded shadow-sm">
<i class="far fa-redo"></i>
</button>
</div>
<div v-if="reports.length" class="list-group">
<div v-for="(report, index) in reports" class="list-group-item">
<div class="media align-items-center">
<img :src="report.profile.avatar" width="40" height="40" class="rounded-circle mr-3">
<div class="media-body">
<p class="mb-0">
<a v-if="report.total_count == 1" class="font-weight-bold" :href="report.profile.url">{{ report.profile.username }}</a>
<span v-else>
<a class="font-weight-bold" :href="report.profile.url">{{ report.profile.username }}</a> and <a class="font-weight-bold" href="#">{{ report.total_count - 1}} others</a>
</span>
reported
<a href="#" class="font-weight-bold" :id="`report_post:${index}`">this post</a>
as {{ report.type }}
</p>
<p class="mb-0 small">
<span class="text-muted font-weight-bold">
{{ timeago(report.created_at) }}
</span>
<!-- <span>·</span>
<a class="text-muted font-weight-bold" href="#">
View Full Report
</a> -->
<span>·</span>
<a class="text-danger font-weight-bold" href="#" @click.prevent="actionMenu(index)">
Actions
</a>
</p>
</div>
<!-- <div class="text-muted">
<button class="btn btn-light btn-sm shadow-sm" @click.prevent="actionMenu(index)">
<i class="far fa-cog fa-lg text-lighter"></i>
</button>
</div> -->
<b-popover :target="`report_post:${index}`" triggers="hover" placement="bottom" custom-class="popover-wide">
<template #title>
<div class="d-flex justify-content-between">
<span>
&commat;{{ report.status.account.username }}
</span>
<span>
{{ timeago(report.status.created_at) }}
</span>
</div>
</template>
<div v-if="report.status.pf_type == 'group:post'">
<div v-if="report.status.media_attachments.length">
<img :src="report.status.media_attachments[0].url" width="100%" height="300" style="object-fit:cover;">
</div>
<div v-else>
<p v-html="report.status.content"></p>
</div>
</div>
<div v-else-if="report.status.pf_type == 'reply-text'">
<p v-html="report.status.content"></p>
</div>
<div v-else>
<p>Cannot generate preview.</p>
</div>
<p class="mb-1 mt-3">
<a class="btn btn-primary btn-block font-weight-bold" :href="report.status.url">View Post</a>
</p>
</b-popover>
</div>
</div>
<div v-if="canLoadMore" class="list-group-item">
<button class="btn btn-light font-weight-bold btn-block" @click.prevent="loadMoreReports()">Load more</button>
</div>
</div>
<div v-else class="card card-body shadow-none border rounded-pill">
<p class="lead font-weight-bold text-center mb-0">No moderation reports found!</p>
</div>
</div>
</div>
</div>
</div>
<div v-else class="col-12 col-md-6 pt-4 d-flex align-items-center justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
group: {
type: Object
}
},
data() {
return {
initalLoad: false,
reports: [],
page: 1,
canLoadMore: false,
tab: 'home'
}
},
mounted() {
this.getReports();
},
methods: {
getReports() {
axios.get(`/api/v0/groups/${this.group.id}/reports/list`)
.then(res => {
this.reports = res.data;
this.initalLoad = true;
this.page++;
this.canLoadMore = res.data.length == 10;
})
},
loadMoreReports() {
axios.get(`/api/v0/groups/${this.group.id}/reports/list`, {
params: {
page: this.page
}
})
.then(res => {
this.reports.push(...res.data);
this.page++;
this.canLoadMore = res.data.length == 10;
})
},
timeago(ts) {
return App.util.format.timeAgo(ts);
},
actionMenu(index) {
event.currentTarget.blur();
swal({
title: "Moderator Action",
dangerMode: true,
text: "Please select an action to take, press ESC to close",
buttons: {
ignore: {
text: "Ignore",
className: "btn-warning",
value: "ignore"
},
cw: {
text: "NSFW",
className: "btn-warning",
value: "cw",
},
delete: {
text: "Delete",
className: "btn-danger",
value: "delete",
},
},
})
.then((value) => {
switch (value) {
case "ignore":
axios.post(`/api/v0/groups/${this.group.id}/report/action`, {
action: 'ignore',
id: this.reports[index].id
}).then(res => {
let report = this.reports[index];
this.$emit('decrement', report.total_count);
this.reports.splice(index, 1);
this.$bvToast.toast(`Ignored and closed moderation report`, {
title: 'Moderation Action',
autoHideDelay: 5000,
appendToast: true
});
})
break;
case "cw":
axios.post(`/api/v0/groups/${this.group.id}/report/action`, {
action: 'cw',
id: this.reports[index].id
}).then(res => {
let report = this.reports[index];
this.$emit('decrement', report.total_count);
this.reports.splice(index, 1);
this.$bvToast.toast(`Succesfully applied content warning and closed moderation report`, {
title: 'Moderation Action',
autoHideDelay: 5000,
appendToast: true
});
})
break;
case "delete":
swal('Oops, this is embarassing!', 'We have not implemented this moderation action yet.', 'error');
break;
}
});
}
}
}
</script>
<style lang="scss">
.group-moderation-component {
min-height: 80vh;
margin-bottom: 100px;
}
.popover-wide {
min-width: 200px !important;
}
</style>

View file

@ -0,0 +1,152 @@
<template>
<div class="group-post-modal">
<b-modal
ref="modal"
size="xl"
hide-footer
hide-header
centered
body-class="gpm p-0">
<div class="d-flex">
<div class="gpm-media">
<img :src="status.media_attachments[0].preview_url">
</div>
<div class="p-3" style="width: 30%;">
<div class="media align-items-center mb-2">
<a :href="status.account.url">
<img class="rounded-circle media-avatar border mr-2" :src="status.account.avatar" width="32" height="32">
</a>
<div class="media-body">
<div class="media-body-comment">
<p class="media-body-comment-username mb-n1">
<a :href="status.account.url" class="text-dark text-decoration-none font-weight-bold">
{{status.account.acct}}
</a>
</p>
<p class="media-body-comment-timestamp mb-0">
<a class="font-weight-light text-muted small" :href="status.url">
{{shortTimestamp(status.created_at)}}
</a>
<span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
<span class="text-muted small"><i class="fas fa-globe"></i></span>
</p>
</div>
</div>
<div class="text-right" style="flex-grow:1;">
<button class="btn btn-link text-dark py-0" type="button">
<span class="fas fa-ellipsis-h text-lighter"></span>
<span class="sr-only">Post Menu</span>
</button>
</div>
</div>
<read-more :status="status" />
<div class="border-top border-bottom mt-3">
<div class="d-flex justify-content-between" style="padding: 8px 5px">
<button class="btn btn-link py-0 text-decoration-none btn-sm" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }">
<i class="far fa-heart mr-1"></i>
{{ status.favourited ? 'Liked' : 'Like' }}
</button>
<button class="btn btn-link py-0 text-decoration-none btn-sm text-muted">
<i class="far fa-comment cursor-pointer text-muted mr-1"></i>
Comment
</button>
<button class="btn btn-link py-0 text-decoration-none btn-sm" disabled>
<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
Share
</button>
</div>
</div>
<!-- <comment-drawer
:profile="profile"
:status="status"
:group-id="groupId" /> -->
</div>
</div>
</b-modal>
</div>
</template>
<script type="text/javascript">
import ReadMore from './ReadMore.vue';
import CommentDrawer from './CommentDrawer.vue';
export default {
props: {
groupId: {
type: String
},
status: {
type: Object
},
profile: {
type: Object
}
},
components: {
"read-more": ReadMore,
"comment-drawer": CommentDrawer
},
data() {
return {
loaded: false
}
},
mounted() {
this.init();
},
methods: {
init() {
this.loaded = true;
this.$refs.modal.show();
},
shortTimestamp(ts) {
return window.App.util.format.timeAgo(ts);
},
}
}
</script>
<style lang="scss">
.gpm {
&-media {
display: flex;
width: 70%;
img {
width: 100%;
height: auto;
max-height: 70vh;
object-fit: contain;
background-color: #000;
}
}
.comment-drawer-component {
.my-3 {
max-height: 46vh;
overflow: auto;
}
}
.cdrawer-reply-form {
position: absolute;
bottom: 0;
margin-bottom: 1rem !important;
min-width: 310px;
}
}
</style>

View file

@ -0,0 +1,199 @@
<template>
<div class="group-search-modal">
<b-modal
ref="modal"
hide-header
hide-footer
centered
rounded
body-class="rounded group-search-modal-wrapper">
<div class="d-flex justify-content-between">
<autocomplete
:search="autocompleteSearch"
placeholder="Search this group"
aria-label="Search this group"
:get-result-value="getSearchResultValue"
:debounceTime="700"
@submit="onSearchSubmit"
style="width: 100%;"
ref="autocomplete"
>
<template #result="{ result, props }">
<li
v-bind="props"
class="autocomplete-result"
>
<div class="text-truncate">
<p class="result-name mb-0 font-weight-bold">
{{ result.username }}
</p>
</div>
</li>
</template>
</autocomplete>
<button class="btn btn-light border rounded-circle text-lighter ml-3" style="width: 52px;height:50px;" @click="close">
<i class="fal fa-times fa-lg"></i>
</button>
</div>
<div v-if="recent && recent.length" class="pt-5">
<h5 class="mb-2">Recent Searches</h5>
<a v-for="(result, index) in recent" class="media align-items-center text-decoration-none text-dark" :href="result.action">
<div class="bg-light rounded-circle mr-3 border d-flex justify-content-center align-items-center" style="width: 40px;height:40px">
<i class="far fa-search"></i>
</div>
<div class="media-body">
<p class="mb-0">{{ result.value }}</p>
</div>
</a>
</div>
<div class="pt-5">
<h5 class="mb-2">Explore This Group</h5>
<div class="media align-items-center" @click="viewMyActivity">
<img
:src="profile?.avatar"
width="40"
height="40"
class="mr-3 border rounded-circle"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
/>
<div class="media-body">
<p class="mb-0">{{ profile?.display_name || profile?.username }}</p>
<p class="mb-0 small text-muted">See your group activity.</p>
</div>
</div>
<div class="media align-items-center" @click="viewGroupSearch">
<div class="bg-light rounded-circle mr-3 border d-flex justify-content-center align-items-center" style="width: 40px;height:40px">
<i class="far fa-search"></i>
</div>
<div class="media-body">
<p class="mb-0">Search all groups</p>
</div>
</div>
</div>
<!-- <div class="text-center py-3 small">
<p class="lead font-weight-normal mb-0">Looking for something?</p>
<p class="mb-0">Search {{ group.name }} for posts, comments or members.</p>
</div> -->
</b-modal>
</div>
</template>
<script type="text/javascript">
import Autocomplete from '@trevoreyre/autocomplete-vue'
import '@trevoreyre/autocomplete-vue/dist/style.css'
export default {
props: {
group: {
type: Object
},
profile: {
type: Object
}
},
components: {
'autocomplete': Autocomplete,
},
data() {
return {
query: '',
recent: [],
loaded: false
}
},
methods: {
open() {
this.fetchRecent();
this.$refs.modal.show();
},
close() {
this.$refs.modal.hide();
},
fetchRecent() {
axios.get('/api/v0/groups/search/getrec', {
params: {
g: this.group.id
}
}).then(res => {
this.recent = res.data;
})
},
autocompleteSearch(q) {
if (!q || q.length < 2) {
return [];
}
return axios.post(`/api/v0/groups/search/lac`, {
q: q,
g: this.group.id,
v: '0.2'
}).then(res => {
return res.data;
});
return [];
},
getSearchResultValue(result) {
return result.username;
},
onSearchSubmit(result) {
if (result.length < 1) {
return [];
}
axios.post('/api/v0/groups/search/addrec', {
g: this.group.id,
q: {
value: result.username,
action: result.url
}
}).then(res => {
location.href = result.url;
});
},
viewMyActivity() {
location.href = `/groups/${this.group.id}/user/${this.profile.id}?rf=group_search`;
},
viewGroupSearch() {
location.href = `/groups/home?ct=gsearch&rf=group_search&rfid=${this.group.id}`;
},
addToRecentSearches() {
}
}
}
</script>
<style lang="scss">
.group-search-modal {
&-wrapper {
.media {
height: 60px;
padding: 10px;
border-radius: 10px;
user-select: none;
cursor: pointer;
&:hover {
background-color: #E5E7EB;
}
}
}
}
</style>

View file

@ -0,0 +1,870 @@
<template>
<div class="status-card-component" :class="{ 'status-card-sm': loaded && size === 'small' }">
<div v-if="loaded" class="shadow-none mb-3">
<div v-if="status.pf_type !== 'poll'" :class="{ 'border-top-0': !hasTopBorder }" class="card shadow-sm" style="border-radius: 18px !important;">
<parent-unavailable
v-if="parentUnavailable == true"
:permalink-mode="permalinkMode"
:permalink-status="childContext"
:status="status"
:profile="profile"
:group-id="groupId"
/>
<div v-else class="card-body pb-0">
<group-post-header
:group="group"
:status="status"
:profile="profile"
:showGroupHeader="showGroupHeader"
:showGroupChevron="showGroupChevron"
/>
<div>
<div>
<div class="pl-2">
<div v-if="status.sensitive && status.content.length" class="card card-body shadow-none border bg-light py-2 my-2 text-center user-select-none cursor-pointer" @click="status.sensitive = false">
<div class="media justify-content-center align-items-center">
<div class="mx-3">
<i class="far fa-exclamation-triangle fa-2x text-lighter"></i>
</div>
<div class="media-body">
<p class="font-weight-bold mb-0">Warning, may contain sensitive content. </p>
<p class="mb-0 text-lighter small text-center font-weight-bold">Click to view</p>
</div>
</div>
</div>
<template v-else>
<p v-html="renderedCaption" class="pt-2 text-break" style="font-size:15px;"></p>
</template>
<photo-presenter
v-if="status.pf_type === 'photo'"
class="col px-0 border mb-4 rounded"
:status="status"
v-on:lightbox="showPostModal"
v-on:togglecw="status.sensitive = false"
@click="showPostModal"/>
<video-presenter
v-else-if="status.pf_type === 'video'"
class="col px-0 border mb-4 rounded"
:status="status"
v-on:togglecw="status.sensitive = false" />
<photo-album-presenter
v-else-if="status.pf_type === 'photo:album'"
class="col px-0 border mb-4 rounded"
:status="status" v-on:lightbox="lightbox"
v-on:togglecw="status.sensitive = false" />
<video-album-presenter
v-else-if="status.pf_type === 'video:album'"
class="col px-0 border mb-4 rounded"
:status="status"
v-on:togglecw="status.sensitive = false" />
<mixed-album-presenter
v-else-if="status.pf_type === 'photo:video:album'"
:status="status"
class="col px-0 border mb-4 rounded"
v-on:lightbox="lightbox"
v-on:togglecw="status.sensitive = false" />
<div v-if="status.favourites_count || status.reply_count" class="border-top my-0">
<div class="d-flex justify-content-between py-2" style="font-size: 14px;">
<button v-if="status.favourites_count" class="btn btn-light py-0 text-decoration-none text-dark" style="font-size: 12px; font-weight: 600;" @click="showLikesModal($event)">
{{ status.favourites_count }} {{ status.favourites_count == 1 ? 'Like' : 'Likes' }}
</button>
<button v-if="status.reply_count" class="btn btn-light py-0 text-decoration-none text-dark" style="font-size: 12px; font-weight: 600;" @click="commentFocus($event)">
{{ status.reply_count }} {{ status.reply_count == 1 ? 'Comment' : 'Comments' }}
</button>
</div>
</div>
<div v-if="profile" class="border-top my-0">
<div class="d-flex justify-content-between py-2 px-4">
<!-- <button class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }"
@click="likeStatus(status, $event);">
<i class="far fa-heart mr-1">
</i>
{{ status.favourited ? 'Liked' : 'Like' }}
</button> -->
<div>
<button :id="'lr__'+status.id" class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }" @click="likeStatus(status, $event);">
<i class="far fa-heart mr-1"></i>
{{ status.favourited ? 'Liked' : 'Like' }}
</button>
</div>
<button class="btn btn-link py-0 text-decoration-none text-muted" @click="commentFocus($event)">
<i class="far fa-comment cursor-pointer text-muted mr-1">
</i>
Comment
</button>
<button class="btn btn-link py-0 text-decoration-none" disabled>
<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1">
</i>
Share
</button>
</div>
</div>
<comment-drawer
v-if="showCommentDrawer"
:permalink-mode="permalinkMode"
:permalink-status="childContext"
:status="status"
:profile="profile"
:group-id="groupId" />
</div>
</div>
</div>
</div>
</div>
<div v-else class="border">
<poll-card :status="status" :profile="profile" v-on:status-delete="statusDeleted" :showBorder="false" />
<div class="bg-white" style="padding: 0 1.25rem">
<div v-if="profile" class="border-top my-0">
<div class="d-flex justify-content-between py-2 px-4">
<!-- <button class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }" @click="likeStatus(status, $event);"> -->
<div>
<button :id="'lr__'+status.id" class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }" @click="likeStatus(status, $event);">
<i class="far fa-heart mr-1"></i>
{{ status.favourited ? 'Liked' : 'Like' }}
</button>
<b-popover :target="'lr__'+status.id" triggers="hover" placement="top">
<template #title>Popover Title</template>
I am popover <b>component</b> content!
</b-popover>
</div>
<button class="btn btn-link py-0 text-decoration-none text-muted" @click="commentFocus($event)">
<i class="far fa-comment cursor-pointer text-muted mr-1"></i>
Comment
</button>
<button class="btn btn-link py-0 text-decoration-none" disabled>
<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1">
</i>
Share
</button>
</div>
</div>
<comment-drawer
v-if="showCommentDrawer"
:permalink-mode="permalinkMode"
:permalink-status="childContext"
:profile="profile"
:status="status"
:group-id="groupId" />
</div>
</div>
<!-- <div v-else class="card rounded-0 border-top-0 status-card card-md-rounded-0 shadow-none border">
<div v-if="status" class="card-header d-inline-flex align-items-center bg-white">
<div>
<img class="rounded-circle box-shadow" :src="status.account.avatar" width="32px" height="32px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
</div>
<div class="pl-2">
<a class="username font-weight-bold text-dark text-decoration-none text-break" v-bind:href="profileUrl(status)" v-html="statusCardUsernameFormat(status)">
Loading...
</a>
<span v-if="status.account.is_admin" class="fa-stack" title="Admin Account" data-toggle="tooltip" style="height:1em; line-height:1em; max-width:19px;">
<i class="fas fa-certificate text-danger fa-stack-1x"></i>
<i class="fas fa-crown text-white fa-sm fa-stack-1x" style="font-size:7px;"></i>
</span>
<div class="d-flex align-items-center">
<a v-if="status.place" class="small text-decoration-none text-muted" :href="'/discover/places/'+status.place.id+'/'+status.place.slug" title="Location" data-toggle="tooltip"><i class="fas fa-map-marked-alt"></i> {{status.place.name}}, {{status.place.country}}</a>
</div>
</div>
<div class="text-right" style="flex-grow:1;">
<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
<span class="fas fa-ellipsis-h text-lighter"></span>
<span class="sr-only">Post Menu</span>
</button>
</div>
</div>
<div class="postPresenterContainer" style="background: #000;">
<div v-if="status.pf_type === 'photo'" class="w-100">
<photo-presenter
:status="status"
v-on:lightbox="lightbox"
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="lightbox" 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="lightbox" v-on:togglecw="status.sensitive = false"></mixed-album-presenter>
</div>
<div v-else class="w-100">
<p class="text-center p-0 font-weight-bold text-white">Error: Problem rendering preview.</p>
</div>
</div>
<div v-if="config.features.label.covid.enabled && status.label && status.label.covid == true" class="card-body border-top border-bottom py-2 cursor-pointer pr-2" @click="labelRedirect()">
<p class="font-weight-bold d-flex justify-content-between align-items-center mb-0">
<span>
<i class="fas fa-info-circle mr-2"></i>
For information about COVID-19, {{config.features.label.covid.org}}
</span>
<span>
<i class="fas fa-chevron-right text-lighter"></i>
</span>
</p>
</div>
<div class="card-body">
<div v-if="reactionBar" class="reactions my-1 pb-2">
<h3 v-if="status.favourited" class="fas fa-heart text-danger pr-3 m-0 cursor-pointer" title="Like" v-on:click="likeStatus(status, $event);"></h3>
<h3 v-else class="far fa-heart pr-3 m-0 like-btn text-dark cursor-pointer" title="Like" v-on:click="likeStatus(status, $event);"></h3>
<h3 v-if="!status.comments_disabled" class="far fa-comment text-dark pr-3 m-0 cursor-pointer" title="Comment" v-on:click="commentFocus(status, $event)"></h3>
<span v-if="status.taggedPeople.length" class="float-right">
<span class="font-weight-light small" style="color:#718096">
<i class="far fa-user" data-toggle="tooltip" title="Tagged People"></i>
<span v-for="(tag, index) in status.taggedPeople" class="mr-n2">
<a :href="'/'+tag.username">
<img :src="tag.avatar" width="20px" height="20px" class="border rounded-circle" data-toggle="tooltip" :title="'@'+tag.username" alt="Avatar">
</a>
</span>
</span>
</span>
</div>
<div v-if="status.liked_by.username && status.liked_by.username !== profile.username" class="likes mb-1">
<span class="like-count">Liked by
<a class="font-weight-bold text-dark" :href="status.liked_by.url">{{status.liked_by.username}}</a>
<span v-if="status.liked_by.others == true">
and <span class="font-weight-bold" v-if="status.liked_by.total_count_pretty">{{status.liked_by.total_count_pretty}}</span> <span class="font-weight-bold">others</span>
</span>
</span>
</div>
<div v-if="status.pf_type != 'text'" class="caption">
<p v-if="!status.sensitive" class="mb-2 read-more" style="overflow: hidden;">
<span class="username font-weight-bold">
<bdi><a class="text-dark" :href="profileUrl(status)">{{status.account.username}}</a></bdi>
</span>
<span class="status-content" v-html="status.content"></span>
</p>
</div>
<div class="timestamp mt-2">
<p class="small mb-0">
<a v-if="status.visibility != 'archived'" :href="statusUrl(status)" class="text-muted text-uppercase">
<timeago :datetime="status.created_at" :auto-update="60" :converter-options="{includeSeconds:true}" :title="timestampFormat(status.created_at)" v-b-tooltip.hover.bottom></timeago>
</a>
<span v-else class="text-muted text-uppercase">
Posted <timeago :datetime="status.created_at" :auto-update="60" :converter-options="{includeSeconds:true}" :title="timestampFormat(status.created_at)" v-b-tooltip.hover.bottom></timeago>
</span>
<span v-if="recommended">
<span class="px-1">&middot;</span>
<span class="text-muted">Based on popular and trending content</span>
</span>
</p>
</div>
</div>
</div> -->
<!-- <div v-else :class="{ 'border-top-0': !hasTopBorder }" class="card shadow-none border rounded-0">
<div class="card-body pb-0">
<div class="media">
<img class="rounded-circle box-shadow mr-2" :src="status.account.avatar" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
<div class="media-body">
<div class="pl-2 d-flex align-items-top">
<div>
<p class="mb-0">
<a class="username font-weight-bold text-dark text-decoration-none text-break" v-bind:href="profileUrl(status)" v-html="statusCardUsernameFormat(status)">
Loading...
</a>
</p>
<p class="mb-0">
<a class="font-weight-light text-muted small"
:href="statusUrl(status)">{{shortTimestamp(status.created_at)}}</a>
<span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
<span class="text-muted small"><i class="fas fa-globe"></i></span>
</p>
</div>
<span class="text-right" style="flex-grow:1;">
<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
<span class="fas fa-ellipsis-h text-lighter"></span>
<span class="sr-only">Post Menu</span>
</button>
</span>
</div>
</div>
</div>
<div>
<div>
<div class="pl-2">
<details v-if="status.sensitive">
<summary class="mb-2 font-weight-bold text-muted">Content Warning</summary>
<p v-html="status.content" class="pt-2 text-break status-content"></p>
</details>
<p v-else v-html="status.content" class="pt-2 text-break" style="font-size:15px;"></p>
<div class="mb-1 row px-3">
<photo-presenter
v-if="status.pf_type === 'photo'"
class="col px-0 border mb-4 rounded"
:status="status"
v-on:lightbox="lightbox"
v-on:togglecw="status.sensitive = false"/>
<video-presenter
v-else-if="status.pf_type === 'video'"
class="col px-0 border mb-4 rounded"
:status="status"
v-on:togglecw="status.sensitive = false" />
<photo-album-presenter
v-else-if="status.pf_type === 'photo:album'"
class="col px-0 border mb-4 rounded"
:status="status" v-on:lightbox="lightbox"
v-on:togglecw="status.sensitive = false" />
<video-album-presenter
v-else-if="status.pf_type === 'video:album'"
class="col px-0 border mb-4 rounded"
:status="status"
v-on:togglecw="status.sensitive = false" />
<mixed-album-presenter
v-else-if="status.pf_type === 'photo:video:album'"
:status="status"
class="col px-0 border mb-4 rounded"
v-on:lightbox="lightbox"
v-on:togglecw="status.sensitive = false" />
</div>
<div>
<div class="pl-2">
<div v-if="status.favourites_count || status.reply_count" class="border-top my-0">
<div class="d-flex justify-content-between py-2" style="font-size: 14px;font-weight: 400;">
<div v-if="status.favourites_count">
{{ status.favourites_count }} Likes
</div>
<button v-if="status.reply_count" class="btn btn-link py-0 text-decoration-none text-dark" @click="commentFocus($event)">
{{ status.reply_count }} Comments
</button>
</div>
</div>
<div class="border-top my-0">
<div class="d-flex justify-content-between py-2 px-4">
<button class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }"
@click="likeStatus(status, $event);">
<i class="far fa-heart mr-1">
</i>
{{ status.favourited ? 'Liked' : 'Like' }}
</button>
<button class="btn btn-link py-0 text-decoration-none text-muted" @click="commentFocus($event)">
<i class="far fa-comment cursor-pointer text-muted mr-1">
</i>
Comment
</button>
<button class="btn btn-link py-0 text-decoration-none" disabled>
<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1">
</i>
Share
</button>
</div>
</div>
<comment-drawer
v-if="showCommentDrawer"
:status="permalinkMode ? parentContext : status"
:profile="profile"
:permalink-mode="permalinkMode"
:permalink-status="childContext"
:group-id="groupId" />
</div>
</div>
</div>
</div>
</div>
</div>
</div> -->
<context-menu
v-if="profile"
ref="contextMenu"
:status="status"
:profile="profile"
:group-id="groupId"
v-on:status-delete="statusDeleted"
/>
<post-modal
v-if="showModal"
ref="modal"
:status="status"
:profile="profile"
:groupId="groupId"
/>
</div>
<div v-else class="card card-body shadow-none border mb-3" style="height: 200px;">
<div class="w-100 h-100 d-flex justify-content-center align-items-center">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
<!-- <b-modal
v-if="likes && likes.length"
ref="likeBox"
title="Liked by"
size="sm"
centered
hide-footer
title="Likes"
body-class="list-group-flush py-3 px-0">
<div class="list-group">
<div class="list-group-item border-0 py-1" v-for="(user, index) in likes" :key="'modal_likes_'+index+status.id">
<div class="media">
<a :href="user.url">
<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + 's avatar'" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
</a>
<div class="media-body">
<p class="mb-0" style="font-size: 14px">
<a :href="user.url" class="font-weight-bold text-dark">
{{user.username}}
</a>
</p>
<p v-if="!user.local" class="text-muted mb-0 text-truncate mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
</p>
<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
{{user.display_name}}
</p>
</div>
</div>
</div>
<infinite-loading v-if="likes && likes.length" @infinite="infiniteLikesHandler" spinner="spiral">
<div slot="no-more"></div>
<div slot="no-results"></div>
</infinite-loading>
</div>
</b-modal> -->
</div>
</template>
<script type="text/javascript">
import CommentDrawer from './CommentDrawer.vue';
import ContextMenu from './ContextMenu.vue';
import PollCard from '~/partials/PollCard.vue';
import MixedAlbumPresenter from '@/presenter/MixedAlbumPresenter.vue';
import PhotoAlbumPresenter from '@/presenter/PhotoAlbumPresenter.vue';
import PhotoPresenter from '@/presenter/PhotoPresenter.vue';
import VideoAlbumPresenter from '@/presenter/VideoAlbumPresenter.vue';
import VideoPresenter from '@/presenter/VideoPresenter.vue';
import GroupPostModal from './GroupPostModal.vue';
import { autoLink } from 'twitter-text';
import ParentUnavailable from '@/groups/partials/Status/ParentUnavailable.vue';
import GroupPostHeader from '@/groups/partials/Status/GroupHeader.vue';
export default {
props: {
groupId: {
type: String
},
group: {
type: Object
},
profile: {
type: Object
},
prestatus: {
type: Object
},
recommended: {
type: Boolean,
default: false
},
reactionBar: {
type: Boolean,
default: true
},
hasTopBorder: {
type: Boolean,
default: true
},
size: {
type: String,
validator: (val) => ['regular', 'small'].includes(val),
default: 'regular'
},
permalinkMode: {
type: Boolean,
default: false
},
showGroupChevron: {
type: Boolean,
default: false
},
showGroupHeader: {
type: Boolean,
default: false
}
},
components: {
"comment-drawer": CommentDrawer,
"context-menu": ContextMenu,
"poll-card": PollCard,
"mixed-album-presenter": MixedAlbumPresenter,
"photo-album-presenter": PhotoAlbumPresenter,
"photo-presenter": PhotoPresenter,
"video-album-presenter": VideoAlbumPresenter,
"video-presenter": VideoPresenter,
"post-modal": GroupPostModal,
"parent-unavailable": ParentUnavailable,
"group-post-header": GroupPostHeader
},
data() {
return {
config: window.App.config,
status: {},
loaded: false,
replies: [],
replyId: null,
lightboxMedia: false,
showSuggestions: true,
showReadMore: true,
replyStatus: {},
replyText: '',
replyNsfw: false,
emoji: window.App.util.emoji,
commentDrawerKey: 0,
showCommentDrawer: false,
parentContext: undefined,
childContext: undefined,
parentUnavailable: undefined,
showModal: false,
likes: [],
likesPage: 1,
openLikesModal: false,
}
},
computed: {
renderedCaption: {
get() {
if(this.prestatus) {
const gid = this.prestatus.gid;
return autoLink(
this.prestatus.content,
{
hashtagUrlBase: App.config.site.url + `/groups/${gid}/topics/`,
usernameUrlBase: App.config.site.url + `/groups/${gid}/username/`
}
)
}
return this.prestatus.content;
}
}
},
mounted() {
this.status = this.prestatus;
let self = this;
setTimeout(() => {
if(this.permalinkMode == true && this.prestatus.in_reply_to_id) {
self.childContext = self.status;
axios.get('/api/v0/groups/status', {
params: {
gid: self.groupId,
sid: self.status.in_reply_to_id
}
}).then(res => {
self.status = res.data;
self.parentUnavailable = false;
self.showCommentDrawer = true;
self.parentContext = res.data;
self.loaded = true;
}).catch(err => {
self.status = this.prestatus;
self.parentUnavailable = true;
self.showCommentDrawer = true;
self.parentContext = this.prestatus;
self.loaded = true;
});
} else {
self.parentUnavailable = false;
self.showCommentDrawer = false;
self.loaded = true;
}
}, 100);
},
methods: {
formatCount(count) {
return App.util.format.count(count);
},
statusUrl(status) {
if(status.local == true) {
return status.url;
}
return '/i/web/post/_/' + status.account.id + '/' + status.id;
},
profileUrl(status) {
if(status.local == true) {
return status.account.url;
}
return '/i/web/profile/_/' + status.account.id;
},
timestampFormat(timestamp) {
let ts = new Date(timestamp);
return ts.toDateString() + ' ' + ts.toLocaleTimeString();
},
shortTimestamp(ts) {
return window.App.util.format.timeAgo(ts);
},
statusCardUsernameFormat(status) {
if(status.account.local == true) {
return status.account.username;
}
let fmt = window.App.config.username.remote.format;
let txt = window.App.config.username.remote.custom;
let usr = status.account.username;
let dom = document.createElement('a');
dom.href = status.account.url;
dom = dom.hostname;
switch(fmt) {
case '@':
return usr + '<span class="text-lighter font-weight-bold">@' + dom + '</span>';
break;
case 'from':
return usr + '<span class="text-lighter font-weight-bold"> <span class="font-weight-normal">from</span> ' + dom + '</span>';
break;
case 'custom':
return usr + '<span class="text-lighter font-weight-bold"> ' + txt + ' ' + dom + '</span>';
break;
default:
return usr + '<span class="text-lighter font-weight-bold">@' + dom + '</span>';
break;
}
},
lightbox(status) {
window.location.href = status.media_attachments[0].url;
},
labelRedirect(type) {
let url = '/i/redirect?url=' + encodeURI(this.config.features.label.covid.url);
window.location.href = url;
},
likeStatus(status, event) {
event.currentTarget.blur();
let count = status.favourites_count;
let state = status.favourited ? 'unlike' : 'like';
axios.post('/api/v0/groups/status/' + state, {
sid: status.id,
gid: this.groupId
}).then(res => {
status.favourited = state;
status.favourites_count = state? count + 1 : count - 1;
status.favourited = state;
if(status.favourited) {
setTimeout(function() {
event.target.classList.add('animate__animated', 'animate__bounce');
},100);
}
}).catch(err => {
if(err.response.status == 422) {
swal('Error', err.response.data.error, 'error');
} else {
swal('Error', 'Something went wrong, please try again later.', 'error');
}
});
window.navigator.vibrate(200);
},
commentFocus($event) {
$event.target.blur();
this.showCommentDrawer = !this.showCommentDrawer;
},
commentSubmit(status, $event) {
this.replySending = true;
let id = status.id;
let comment = this.replyText;
let limit = this.config.uploader.max_caption_length;
if(comment.length > limit) {
this.replySending = false;
swal('Comment Too Long', 'Please make sure your comment is '+limit+' characters or less.', 'error');
return;
}
axios.post('/i/comment', {
item: id,
comment: comment,
sensitive: this.replyNsfw
}).then(res => {
this.replyText = '';
this.replies.push(res.data.entity);
this.$refs.replyModal.hide();
});
this.replySending = false;
},
owner(status) {
return this.profile.id === status.account.id;
},
admin() {
return this.profile.is_admin == true;
},
ownerOrAdmin(status) {
return this.owner(status) || this.admin();
},
ctxMenu() {
this.$refs.contextMenu.open();
},
timeAgo(ts) {
return App.util.format.timeAgo(ts);
},
statusDeleted(status) {
this.$emit('status-delete');
},
showPostModal() {
this.showModal = true;
this.$refs.modal.init();
},
showLikesModal(event) {
if(event && event.hasOwnProperty('currentTarget')) {
event.currentTarget().blur();
}
this.$emit('likes-modal');
return;
if(this.likes.length) {
this.$refs.likeBox.show();
return;
}
axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.status.id)
.then(res => {
this.likes = res.data.data;
this.$refs.likeBox.show();
});
},
infiniteLikesHandler($state) {
axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.status.id, {
params: {
page: this.likesPage,
},
}).then(({ data }) => {
if (data.data.length > 0) {
this.likes.push(...data.data);
this.likesPage++;
$state.loaded();
} else {
$state.complete();
}
});
},
}
}
</script>
<style lang="scss">
.status-card-component {
.status-content {
font-size: 17px;
}
&.status-card-sm {
.status-content {
font-size: 14px;
}
.fa-lg {
font-size: unset;
line-height: unset;
vertical-align: unset;
}
}
}
.reaction-bar {
width: auto;
max-width: unset;
left: -50px !important;
border: 1px solid #F3F4F6 !important;
.popover-body {
padding: 2px;
}
.arrow {
display: none;
}
img {
width: 48px;
}
}
</style>

View file

@ -0,0 +1,73 @@
<template>
<div class="group-topics-component">
<div class="row justify-content-center">
<div class="col-12 col-md-8">
<div class="card card-body border shadow-sm">
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="h4 font-weight-bold mb-0">Group Topics</p>
<select class="form-control bg-light rounded-lg border font-weight-bold py-2" style="width:95px;" disabled>
<option>All</option>
<option>Pinned</option>
</select>
</div>
<div v-if="feed.length">
<div v-for="(tag, index) in feed" class="">
<div class="media py-2">
<i class="fas fa-hashtag fa-lg text-lighter mr-3 mt-2"></i>
<div :class="{ 'border-bottom': index != feed.length - 1 }" class="media-body">
<a :href="tag.url" class="font-weight-bold mb-1 text-dark" style="font-size: 16px;">
{{ tag.name }}
</a>
<p style="font-size: 13px;" class="text-muted">{{ tag.count }} posts in this group</p>
</div>
</div>
</div>
</div>
<div v-else class="py-5">
<p class="lead text-center font-weight-bold">No topics found</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
group: {
type: Object
}
},
data() {
return {
feed: []
}
},
mounted() {
this.fetchTopics();
},
methods: {
fetchTopics() {
axios.get('/api/v0/groups/topics/list', {
params: {
gid: this.group.id
}
}).then(res => {
this.feed = res.data;
})
}
}
}
</script>
<style lang="scss">
.group-topics-component {
margin-bottom: 50vh;
}
</style>

View file

@ -0,0 +1,9 @@
<template>
</template>
<script type="text/javascript">
export default {
}
</script>

View file

@ -0,0 +1,172 @@
<template>
<div>
<b-modal
v-if="profile && profile.hasOwnProperty('avatar')"
ref="home"
hide-footer
centered
rounded
title="Limit Interactions"
body-class="rounded">
<div class="media mb-3">
<a :href="profile.url" class="text-dark text-decoration-none">
<img :src="profile.avatar" width="56" height="56" class="rounded-circle border mr-2" />
</a>
<div class="media-body">
<p class="lead font-weight-bold mb-0">
<a :href="profile.url" class="text-dark text-decoration-none">{{profile.username}}</a>
<span v-if="profile.role == 'founder'" class="member-label rounded ml-1">Admin</span>
</p>
<p class="text-muted mb-0">Member since {{formatDate(profile.joined)}}</p>
</div>
</div>
<div class="w-100 bg-light mb-1 font-weight-bold d-flex justify-content-center align-items-center border rounded" style="min-height:240px;">
<div v-if="limitsLoaded" class="py-3">
<p class="lead mb-0">Interaction Permissions</p>
<p class="small text-muted">Last updated: {{ updated ? formatDate(updated) : 'Never' }}</p>
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="limits.can_post" :disabled="savingChanges">
<label class="form-check-label">
Can create posts
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="limits.can_comment" :disabled="savingChanges">
<label class="form-check-label">
Can create comments
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="limits.can_like" :disabled="savingChanges">
<label class="form-check-label">
Can like posts and comments
</label>
</div>
<hr>
<button class="btn btn-primary font-weight-bold float-right" @click.prevent="saveChanges" :disabled="savingChanges" style="width:130px;">
<b-spinner v-if="savingChanges" variant="light" small />
<span v-else>Save changes</span>
</button>
</div>
<div v-else class="d-flex align-items-center flex-column">
<b-spinner variant="muted" />
<p class="pt-3 small text-muted font-weight-bold">Loading interaction limits...</p>
</div>
</div>
</b-modal>
</div>
</template>
<script type="text/javascript">
export default {
props: {
group: {
type: Object
},
profile: {
type: Object
}
},
data() {
return {
limitsLoaded: false,
limits: {
can_post: true,
can_comment: true,
can_like: true
},
updated: null,
savingChanges: false
}
},
methods: {
fetchInteractionLimits() {
axios.get(`/api/v0/groups/${this.group.id}/members/interaction-limits`, {
params: {
profile_id: this.profile.id
}
})
.then(res => {
this.limits = res.data.limits;
this.updated = res.data.updated_at;
this.limitsLoaded = true;
}).catch(err => {
this.$refs.home.hide();
swal('Oops!', 'Cannot fetch interaction limits at this time, please try again later.', 'error');
})
},
open(profile) {
this.loaded = true;
this.$refs.home.show();
this.fetchInteractionLimits();
},
formatDate(ts) {
return new Date(ts).toDateString();
},
saveChanges() {
event.currentTarget.blur();
this.savingChanges = true;
axios.post(`/api/v0/groups/${this.group.id}/members/interaction-limits`, {
profile_id: this.profile.id,
can_post: this.limits.can_post,
can_comment: this.limits.can_comment,
can_like: this.limits.can_like,
})
.then(res => {
this.savingChanges = false;
this.$refs.home.hide();
this.$bvToast.toast(`Updated interaction limits for ${this.profile.username}`, {
title: 'Success',
variant: 'success',
autoHideDelay: 5000
});
}).catch(err => {
this.savingChanges = false;
this.$refs.home.hide();
if(err.response.status == 422 && err.response.data.error == 'limit_reached') {
swal('Limit Reached', 'You cannot add any more member limitations', 'info');
// swal({
// title: 'Limit Reached',
// text: 'You cannot add any more member limitations',
// icon: 'info',
// buttons: {
// info: {
// className: 'btn-light border',
// text: 'More info',
// value: 'more-info'
// },
// ok: {
// text: 'Ok',
// value: null
// },
// }
// }).then(value => {
// if(value == 'more-info') {
// location.href = '/site/kb/groups/interaction-limits';
// }
// });
} else {
swal('Oops!', 'An error occured while processing this request, please try again later.', 'error');
}
});
}
}
}
</script>

View file

@ -0,0 +1,38 @@
<template>
<div class="member-only-warning">
<div class="member-only-warning-wrapper">
<h3>Content unavailable</h3>
<p>You need to join this Group before you can access this content.</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.member-only-warning {
display: flex;
justify-content: center;
&-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
width: 100%;
max-width: 550px;
border-radius: 10px;
border: 1px solid var(--border-color);
padding: 4rem 1rem;
h3 {
font-weight: bold;
letter-spacing: -1px;
}
p {
font-size: 1.2em;
margin-bottom: 0px;
}
}
}
</style>

View file

@ -0,0 +1,44 @@
<template>
<div class="px-md-5" style="background-color: #fff;">
<img
v-if="group.metadata && group.metadata.hasOwnProperty('header')"
:src="group.metadata.header.url"
class="header-image">
<div v-else class="header-jumbotron"></div>
</div>
</template>
<script>
export default {
props: {
group: {
type: Object
}
}
}
</script>
<style lang="scss" scoped>
.header-image {
width: 100%;
height: auto;
object-fit: cover;
max-height: 220px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
margin-top:-1px;
border: 1px solid var(--light);
margin-bottom: 0px;
@media(min-width: 768px) {
max-height: 420px;
}
}
.header-jumbotron {
background-color: #F3F4F6;
height: 320px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
</style>

View file

@ -0,0 +1,199 @@
<template>
<div class="col-12 group-feed-component-header px-3 px-md-5">
<div class="media align-items-end">
<img
v-if="group.metadata && group.metadata.hasOwnProperty('avatar')"
:src="group.metadata.avatar.url"
width="169"
height="169"
class="bg-white mx-4 rounded-circle border shadow p-1"
style="object-fit: cover;"
:style="{ 'margin-top': group.metadata && group.metadata.hasOwnProperty('header') && group.metadata.header.url ? '-100px' : '0' }"
/>
<div v-if="!group || !group.name" class="media-body">
<h3 class="d-flex align-items-start">
<span>Loading...</span>
</h3>
</div>
<div v-else class="media-body px-3">
<h3 class="d-flex align-items-start">
<span>{{ group.name.slice(0,118) }}</span>
<sup v-if="group.verified" class="fa-stack ml-n2" title="Verified Group" data-toggle="tooltip">
<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
</sup>
</h3>
<p class="text-muted mb-0" style="font-weight: 300;">
<span>
<i class="fas fa-globe mr-1"></i>
{{ group.membership == 'all' ? 'Public Group' : 'Private Group'}}
</span>
<span class="mx-2">
·
</span>
<span>{{ group.member_count == 1 ? group.member_count + ' Member' : group.member_count + ' Members' }}</span>
<span class="mx-2">
·
</span>
<span v-if="group.local" class="rounded member-label">Local</span>
<span v-else class="rounded remote-label">Remote</span>
<span v-if="group.self && group.self.hasOwnProperty('role') && group.self.role">
<span class="mx-2">
·
</span>
<span class="rounded member-label">{{ group.self.role }}</span>
</span>
</p>
</div>
</div>
<div v-if="group && group.self">
<button v-if="!isMember && !group.self.is_requested" class="btn btn-primary cta-btn font-weight-bold" @click="joinGroup" :disabled="requestingMembership">
<span v-if="!requestingMembership">
{{ group.membership == 'all' ? 'Join' : 'Request Membership' }}
</span>
<div
v-else
class="spinner-border spinner-border-sm"
role="status">
<span class="sr-only">Loading...</span>
</div>
</button>
<button
v-else-if="!isMember && group.self.is_requested"
class="btn btn-light border cta-btn font-weight-bold"
@click.prevent="cancelJoinRequest">
<i class="fas fa-user-clock mr-1"></i> Requested to Join
</button>
<button
v-else-if="!isAdmin && isMember && !group.self.is_requested"
type="button"
class="btn btn-light border cta-btn font-weight-bold"
@click.prevent="leaveGroup">
<i class="fas sign-out-alt mr-1"></i> Leave Group
</button>
<!-- <div v-if="isAdmin">
<a
class="btn btn-light border cta-btn font-weight-bold"
:href="group.url + '/settings'">
Settings
</a>
</div> -->
</div>
</div>
</template>
<script>
export default {
props: {
group: {
type: Object
},
isAdmin: {
type: Boolean,
default: false
},
isMember: {
type: Boolean,
default: false
}
},
data() {
return {
requestingMembership: false,
}
},
methods: {
joinGroup() {
this.requestingMembership = true;
axios.post('/api/v0/groups/'+this.group.id+'/join')
.then(res => {
this.requestingMembership = false;
this.$emit('refresh');
}).catch(err => {
let body = err.response;
if(body.status == 422) {
this.requestingMembership = false;
swal('Oops!', body.data.error, 'error');
}
});
},
cancelJoinRequest() {
if(!window.confirm('Are you sure you want to cancel your request to join this group?')) {
return;
}
axios.post('/api/v0/groups/'+this.group.id+'/cjr')
.then(res => {
this.requestingMembership = false;
this.$emit('refresh');
}).catch(err => {
let body = err.response;
if(body.status == 422) {
swal('Oops!', body.data.error, 'error');
}
});
},
leaveGroup() {
if(!window.confirm('Are you sure you want to leave this group? Any content you shared will remain accessible. You won\'t be able to rejoin for 24 hours.')) {
return;
}
axios.post('/api/v0/groups/'+this.group.id+'/leave')
.then(res => {
this.$emit('refresh');
});
},
}
}
</script>
<style lang="scss">
.group-feed-component {
&-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 1rem 0;
background-color: transparent;
.cta-btn {
width: 190px;
}
}
.member-label {
padding: 2px 5px;
font-size: 12px;
color: rgba(75, 119, 190, 1);
background:rgba(137, 196, 244, 0.2);
border:1px solid rgba(137, 196, 244, 0.3);
font-weight:400;
text-transform: capitalize;
}
.dropdown-item {
font-weight: 600;
}
.remote-label {
padding: 2px 5px;
font-size: 12px;
color: #B45309;
background: #FEF3C7;
border: 1px solid #FCD34D;
font-weight: 400;
text-transform: capitalize;
}
}
</style>

View file

@ -0,0 +1,167 @@
<template>
<div>
<div class="col-12 border-top group-feed-component-menu px-5">
<ul class="nav font-weight-bold group-feed-component-menu-nav">
<li class="nav-item">
<router-link :to="`/groups/${group.id}/about`" class="nav-link">About</router-link>
</li>
<li class="nav-item">
<router-link :to="`/groups/${group.id}`" class="nav-link" exact>Feed</router-link>
</li>
<li v-if="group?.self && group.self.is_member" class="nav-item">
<router-link :to="`/groups/${group.id}/topics`" class="nav-link">Topics</router-link>
</li>
<li v-if="group?.self && group.self.is_member" class="nav-item">
<router-link :to="`/groups/${group.id}/members`" class="nav-link">
Members
<span v-if="group.self.is_member && isAdmin && atabs.request_count" class="badge badge-danger rounded-pill ml-2" style="height: 20px;padding:4px 8px;font-size: 11px;">{{atabs.request_count}}</span>
</router-link>
</li>
<!-- <li v-if="group.self.is_member" class="nav-item">
<a :class="{active: tab == 'events'}" class="nav-link" href="#" @click.prevent="switchTab('events')">Events</a>
</li> -->
<li v-if="group?.self && group.self.is_member" class="nav-item">
<router-link :to="`/groups/${group.id}/media`" class="nav-link">Media</router-link>
</li>
<!-- <li v-if="group.self.is_member" class="nav-item">
<a class="nav-link" href="#">Popular</a>
</li> -->
<!-- <li v-if="group.self.is_member" class="nav-item">
<a :class="{active: tab == 'polls'}" class="nav-link" href="#" @click.prevent="switchTab('polls')">Polls</a>
</li> -->
<!-- <li v-if="group.self.is_member && isAdmin" class="nav-item">
<a class="nav-link" href="#">Messages</a>
</li> -->
<!-- <li v-if="group.self.is_member && isAdmin" class="nav-item">
<a :class="{active: tab == 'insights'}" class="nav-link" href="#" @click.prevent="switchTab('insights')">Insights</a>
</li> -->
<!-- <li v-if="group.self.is_member && isAdmin && group.membership != 'all'" class="nav-item">
<a :class="{active: tab == 'requests'}" class="nav-link" href="#" @click.prevent="switchTab('requests')">
<span class="mr-2">
<i class="far fa-user-plus mr-1"></i>
Requests
</span>
<span v-if="atabs.request_count" class="badge badge-danger rounded-pill" style="height: 20px;padding:4px 8px;font-size: 11px;">{{atabs.request_count}}</span>
<span v-if="atabs.request_count" class="badge badge-danger rounded-pill" style="height: 20px;padding:4px 8px;font-size: 11px;">99+</span>
</a>
</li> -->
<li v-if="group?.self && group.self.is_member && isAdmin" class="nav-item">
<router-link :to="`/groups/${group.id}/moderation`" class="nav-link d-flex align-items-top">
<span class="mr-2">Moderation</span>
<span v-if="atabs.moderation_count" class="badge badge-danger rounded-pill" style="height: 20px;padding:4px 8px;font-size: 11px;">{{atabs.moderation_count}}</span>
</router-link>
</li>
</ul>
<div>
<button
v-if="group?.self && group.self.is_member"
class="btn btn-light btn-sm border px-3 rounded-pill mr-2"
@click="showSearchModal">
<i class="far fa-search"></i>
</button>
<div class="dropdown d-inline">
<button class="btn btn-light btn-sm border px-3 rounded-pill dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="far fa-cog"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="#" @click.prevent="copyLink">
Copy Group Link
</a>
<a class="dropdown-item" href="#" @click.prevent="showInviteModal">
Invite friends
</a>
<a v-if="!isAdmin" class="dropdown-item" href="#" @click.prevent="reportGroup">
Report Group
</a>
<a v-if="isAdmin" class="dropdown-item" :href="group.url + '/settings'">
Settings
</a>
</div>
</div>
</div>
</div>
<search-modal
ref="searchModal"
:group="group"
:profile="profile"
/>
</div>
</template>
<script>
import SearchModal from '@/groups/partials/GroupSearchModal.vue';
export default {
props: {
group: {
type: Object
},
isAdmin: {
type: Boolean,
default: false
},
isMember: {
type: Boolean,
default: false
},
atabs: {
type: Object
},
profile: {
type: Object
}
},
components: {
'search-modal': SearchModal,
},
methods: {
showSearchModal() {
event.currentTarget.blur();
this.$refs.searchModal.open();
},
// showInviteModal() {
// event.currentTarget.blur();
// this.$refs.inviteModal.open();
// },
}
}
</script>
<style lang="scss">
.group-feed-component {
&-menu {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
&-nav {
.nav-item {
.nav-link {
padding-top: 1rem;
padding-bottom: 1rem;
color: #6c757d;
&.active {
color: #2c78bf;
border-bottom: 2px solid #2c78bf;
}
}
}
&:not(last-child) {
.nav-item {
margin-right: 14px;
}
}
}
}
}
</style>

View file

@ -0,0 +1,51 @@
<template>
<div class="read-more-component" style="word-break: break-all;">
<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> Read more...
</a>
</div>
</div>
</template>
<script type="text/javascript">
export default {
props: {
status: {
type: Object
},
cursorLimit: {
type: Number,
default: 200
}
},
data() {
return {
fullContent: null,
content: null,
cursor: 200
}
},
mounted() {
this.cursor = this.cursorLimit;
this.fullContent = this.status.content;
this.content = this.status.content.substr(0, this.cursor);
},
methods: {
readMore() {
this.cursor = this.cursor + 200;
this.content = this.fullContent.substr(0, this.cursor);
}
}
}
</script>

View file

@ -0,0 +1,465 @@
<template>
<div class="self-discover-component col-12 col-md-9 bg-lighter border-left mb-4">
<div class="px-5">
<div class="jumbotron my-4 text-light bg-mantle">
<div class="container">
<h1 class="display-4">Discover</h1>
<p class="lead mb-0">Explore group communities and topics</p>
<!-- <p class="lead">
<button class="btn btn-outline-light">Browse Categories</button>
</p> -->
</div>
</div>
</div>
<div v-if="tab === 'home'" class="px-5">
<div class="row mb-4 pt-5">
<div class="col-12 col-md-4">
<h4 class="font-weight-bold">Popular</h4>
<div class="list-group list-group-scroll">
<a v-for="(group, index) in popularGroups"
class="list-group-item p-1"
:href="group.url">
<group-list-card :group="group" :compact="true" />
</a>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card card-body shadow-none bg-mantle text-light" style="margin-top: 33px;">
<h3 class="mb-4 font-weight-lighter">Discover communities and topics based on your interests</h3>
<p class="mb-0">
<button class="btn btn-outline-light font-weight-light btn-block" @click="toggleTab('categories')">Browse Categories</button>
</p>
</div>
<div class="card card-body shadow-none bg-light text-dark border" style="margin-top: 20px;">
<p class="lead mb-4 text-muted font-weight-lighter mb-1">Browse Public Groups</p>
<!-- <p class="lead mb-4 text-muted font-weight-lighter">Tips for growing your group membership</p> -->
<!-- <h5 class="mb-4 text-muted font-weight-lighter">Create and easily organize events</h5> -->
<p class="mb-0">
<button class="btn btn-light border font-weight-light btn-block">Group Directory</button>
</p>
</div>
</div>
<div class="col-12 col-md-4">
<h4 class="font-weight-bold">New</h4>
<div class="list-group list-group-scroll">
<a v-for="(group, index) in newGroups"
class="list-group-item p-1"
:href="group.url">
<group-list-card :group="group" :compact="true" />
</a>
</div>
</div>
</div>
<div class="jumbotron mb-4 text-light bg-black" style="margin-top: 5rem;">
<div class="container">
<h1 class="display-4">Across the Fediverse</h1>
<p class="mb-0">
<button
class="btn btn-outline-light"
@click="toggleTab('fediverseGroups')"
>
Explore fediverse groups <i class="fal fa-chevron-right ml-2"></i>
</button>
</p>
<hr class="my-4">
<p class="lead">We're in the early stages of Group federation, and working with other projects to support cross-platform compatibility. <a href="#">Learn more about group federation <i class="fal fa-chevron-right ml-2 fa-sm"></i></a></p>
</div>
</div>
<div class="row my-4 py-5">
<div class="col-12 col-md-4">
<div class="card card-body shadow-none bg-light" style="border:1px solid #E5E7EB;">
<p class="text-center text-lighter">
<i class="fal fa-lightbulb fa-4x"></i>
</p>
<p class="text-center lead mb-0">What's New</p>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card card-body shadow-none bg-light" style="border:1px solid #E5E7EB;">
<p class="text-center text-lighter">
<i class="fal fa-clipboard-list-check fa-4x"></i>
</p>
<p class="text-center lead mb-0">User Guide</p>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card card-body shadow-none bg-light" style="border:1px solid #E5E7EB;">
<p class="text-center text-lighter">
<i class="fal fa-question-circle fa-4x"></i>
</p>
<p class="text-center lead mb-0">Groups Help</p>
</div>
</div>
</div>
<p class="text-lighter" style="font-size:9px">
<span class="font-weight-bold mr-1">Groups v0.0.1</span>
</p>
<!-- <div class="my-4 pt-5">
<p class="h4 font-weight-bold mb-1">Suggested for You</p>
<p class="lead text-muted mb-0">Groups you might be interested in</p>
</div>
<div class="row mb-4">
<div v-for="(group, index) in recommended.slice(recommendedStart, recommendedEnd)" :key="'rec:'+group.id+':'+index" class="col-12 col-md-4 slide-fade">
<div class="card shadow-sm border text-decoration-none text-dark">
<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: auto;object-fit: cover;max-height: 160px;">
<div v-else class="bg-primary" style="width:100%;height:160px;"></div>
<div class="card-body">
<div class="lead font-weight-bold d-flex align-items-top" style="height: 60px;">
{{ group.name }}
<span v-if="group.verified" class="fa-stack ml-n2 mt-n2">
<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
</span>
</div>
<div class="text-muted font-weight-light d-flex justify-content-between">
<span>{{group.member_count}} Members</span>
</div>
<hr>
<p class="mb-0">
<a class="btn btn-light btn-block border rounded-lg font-weight-bold" :href="group.url">View Group</a>
</p>
</div>
</div>
<div v-if="index == 0 && recommended.length > 3 && recommendedStart != 0" style="position: absolute; top: 45%; left: 0px;transform:translateY(-55%);">
<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="recommendedPrev()">
<i class="fas fa-chevron-left fa-lg"></i>
</button>
</div>
<div v-if="index == 2 && recommended.length > 3" style="position: absolute; top: 45%; right: 0px;transform:translateY(-55%);">
<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="recommendedNext()">
<i class="fas fa-chevron-right fa-lg"></i>
</button>
</div>
</div>
</div>
<div class="py-2 mb-2">
<hr>
</div> -->
<!-- <div class="px-4 pb-4">
<p class="h4 font-weight-bold mb-1">Friends' Groups</p>
<p class="lead text-muted mb-0">Groups your mutuals are in.</p>
</div> -->
<!-- <div class="px-4 py-2">
<hr>
</div> -->
<!-- <div class="pb-4">
<p class="h4 font-weight-bold mb-1">Categories</p>
<p class="lead text-muted mb-0">Find a group by browsing top categories</p>
</div>
<div class="row mb-4">
<div v-for="(group, index) in categories.slice(categoriesStart, categoriesEnd)" :key="'rec:'+group.id+':'+index" class="col-12 col-md-2 slide-fade">
<div class="card card-body rounded-lg shadow-sm border text-decoration-none bg-primary p-2 text-white d-flex justify-content-end" style="width: 150px; height:150px; background: linear-gradient(45deg, #ff512f, #dd2476);">
<p class="mb-0 font-weight-bold" style="font-size:15px">{{group}}</p>
</div>
<div v-if="index == 0 && categories.length > 3 && categoriesStart != 0" style="position: absolute; top: 50%; left: -10px;transform:translateY(-50%);">
<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="categoriesPrev()">
<i class="fas fa-chevron-left fa-lg"></i>
</button>
</div>
<div v-if="index == 5 && categories.length > 3" style="position: absolute; top: 50%; right: -10px;transform:translateY(-50%);">
<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="categoriesNext()">
<i class="fas fa-chevron-right fa-lg"></i>
</button>
</div>
</div>
</div> -->
<!-- <div class="py-2 mb-2">
<hr>
</div> -->
<!-- <div class="pb-4 my-4">
<p class="h4 font-weight-bold mb-1">My Groups</p>
<p class="lead text-muted mb-0">Groups you are a member of</p>
</div>
<div class="row mb-4">
<div v-for="(group, index) in selfGroups.slice(selfGroupsStart, selfGroupsEnd)" :key="'rec:'+group.id+':'+index" class="col-12 col-md-4 slide-fade">
<div class="card shadow-sm border text-decoration-none text-dark">
<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: auto;object-fit: cover;max-height: 160px;">
<div v-else class="bg-primary" style="width:100%;height:160px;"></div>
<div class="card-body">
<div class="lead font-weight-bold d-flex align-items-top" style="height: 60px;">
{{ group.name }}
<span v-if="group.verified" class="fa-stack ml-n2 mt-n2">
<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
</span>
</div>
<div class="text-muted font-weight-light d-flex justify-content-between">
<span>{{group.member_count}} Members</span>
</div>
<hr>
<p class="mb-0">
<a class="btn btn-light btn-block border rounded-lg font-weight-bold" :href="group.url">View Group</a>
</p>
</div>
</div>
<div v-if="index == 0 && selfGroups.length > 3 && selfGroupsStart != 0" style="position: absolute; top: 50%; left: -10px;transform:translateY(-50%);">
<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="selfGroupsPrev()">
<i class="fas fa-chevron-left fa-lg"></i>
</button>
</div>
<div v-if="index == 2 && selfGroups.length > 3" style="position: absolute; top: 50%; right: -10px;transform:translateY(-50%);">
<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="selfGroupsNext()">
<i class="fas fa-chevron-right fa-lg"></i>
</button>
</div>
</div>
</div> -->
</div>
<div v-if="tab === 'categories'" class="px-5">
<div class="row my-4 justify-content-center">
<div class="col-12 col-md-6">
<div class="title mb-4">
<span>Categories</span>
<button class="btn btn-light font-weight-bold" @click="toggleTab('home')">Go Back</button>
</div>
<div class="list-group">
<div
v-for="(group, index) in categories"
:key="'rec:'+group.id+':'+index"
class="list-group-item"
@click="selectCategory(index)">
<p class="mb-0 font-weight-bold">
{{group}}
<span class="float-right">
<i class="fal fa-chevron-right"></i>
</span>
</p>
</div>
</div>
</div>
</div>
</div>
<div v-if="tab === 'category'" class="px-5">
<div class="row my-4 justify-content-center">
<div class="col-12 col-md-6">
<div class="title mb-4">
<div>
<div class="mb-n2 small text-uppercase text-lighter">Categories</div>
<span>{{ categories[activeCategoryIndex] }}</span>
</div>
<button class="btn btn-light font-weight-bold" @click="toggleTab('categories')">Go Back</button>
</div>
<div v-if="categoryGroupsLoaded">
<div class="list-group">
<a v-for="(group, index) in categoryGroups"
class="list-group-item p-1"
:href="group.url">
<group-list-card :group="group" :showStats="true" />
</a>
<div
v-if="categoryGroupsCanLoadMore"
class="list-group-item">
<button
class="btn btn-light font-weight-bold btn-block"
@click="fetchCategoryGroups">
Load more
</button>
</div>
</div>
<div v-if="categoryGroups.length === 0" class="mt-3">
<div class="bg-white border text-center p-3">
<p class="font-weight-light mb-0">No groups found in this category</p>
</div>
</div>
</div>
<div v-else>
<div class="card card-body shadow-none border justify-content-center flex-row">
<b-spinner />
</div>
</div>
</div>
</div>
</div>
<div v-if="tab === 'fediverseGroups'" class="px-5">
<div class="row my-4 justify-content-center">
<div class="col-12 col-md-6">
<div class="title mb-4">
<span>Fediverse Groups</span>
<button class="btn btn-light font-weight-bold" @click="toggleTab('home')">Go Back</button>
</div>
<div class="mt-3">
<div class="bg-white border text-center p-3">
<p class="font-weight-light mb-0">No fediverse groups found</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import GroupListCard from './GroupListCard.vue';
export default {
props: {
profile: {
type: Object
}
},
components: {
"group-list-card": GroupListCard
},
data() {
return {
isLoaded: false,
tab: 'home',
popularGroups: [],
newGroups: [],
activeCategoryIndex: undefined,
activeCategoryPage: 1,
categories: [],
categoriesStart: 0,
categoriesEnd: 6,
categoryGroups: [],
categoryGroupsLoaded: false,
categoryGroupsCanLoadMore: false,
// selfGroups: [],
// selfGroupsStart: 0,
// selfGroupsEnd: 3
}
},
mounted() {
this.fetchPopular();
this.fetchCategories();
// this.fetchSelf();
},
methods: {
fetchPopular() {
axios.get('/api/v0/groups/discover/popular')
.then(res => {
this.popularGroups = res.data;
this.fetchNew();
})
},
fetchNew() {
axios.get('/api/v0/groups/discover/new')
.then(res => {
this.newGroups = res.data;
})
},
fetchCategories() {
axios.get('/api/v0/groups/categories/list')
.then(res => {
this.categories = res.data;
})
},
toggleTab(tab) {
window.scrollTo(0, 0);
this.tab = tab;
},
selectCategory(index) {
window.scrollTo(0, 0);
if(index !== this.activeCategoryIndex) {
this.activeCategoryPage = 1;
}
this.activeCategoryIndex = index;
this.fetchCategoryGroups();
},
fetchCategoryGroups() {
if(this.activeCategoryPage == 1) {
this.categoryGroupsLoaded = false;
}
axios.get('/api/v0/groups/category/list', {
params: {
name: this.categories[this.activeCategoryIndex],
page: this.activeCategoryPage
}
})
.then(res => {
this.tab = 'category';
if(this.activeCategoryPage == 1) {
this.categoryGroups = res.data;
} else {
this.categoryGroups.push(...res.data);
}
if(res.data.length == 6) {
this.categoryGroupsCanLoadMore = true;
this.activeCategoryPage++;
} else {
this.categoryGroupsCanLoadMore = false;
}
setTimeout(() => {
this.categoryGroupsLoaded = true;
}, 600);
})
}
}
}
</script>
<style lang="scss">
.self-discover-component {
.list-group-item {
text-decoration: none;
&:hover {
background-color: #F3F4F6;
}
}
.bg-mantle {
background: linear-gradient(45deg, #24c6dc, #514a9d);
}
.bg-black {
background-color: #000;
hr {
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
}
.title {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 24px;
font-weight: 600;
}
.btn {
border: 1px solid #E5E7EB;
}
}
}
</style>

View file

@ -0,0 +1,146 @@
<template>
<div class="col-12 col-md-9" style="overflow:hidden">
<div class="row h-100 bg-light justify-content-center">
<div class="col-12 col-md-10 col-lg-7">
<div v-if="!initalLoad">
<p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
</div>
<div v-else class="px-5">
<div v-if="emptyFeed">
<div class="jumbotron mt-5">
<h1 class="display-4">Hello 👋</h1>
<h3 class="font-weight-light">Welcome to Pixelfed Groups!</h3>
<p class="lead">Groups are a way to participate in like minded communities and topics.</p>
<hr class="my-4">
<p>Anyone can create and manage their own group as long as it abides by our <a href="#">community guidelines</a>.</p>
<p class="text-center mb-0">
<router-link to="/groups/discover" class="btn btn-primary btn-lg rounded-pill">
Discover Groups
</router-link>
</p>
</div>
</div>
<div v-else>
<div class="my-4">
<p class="h1 font-weight-bold mb-1">Groups Feed</p>
<p class="lead text-muted mb-0">Recent posts from your groups</p>
</div>
<div class="my-3">
<group-status
v-for="(status, index) in feed"
:key="'gs:' + status.id + index"
:prestatus="status"
:profile="profile"
:show-group-header="true"
:group="status.group"
:group-id="status.group.id" />
<div v-if="feed.length > 2">
<infinite-loading @infinite="infiniteFeed" :distance="800">
<div slot="no-more" class="my-3">
<p class="lead font-weight-bold pt-5">You have reached the end of this feed</p>
<div style="height: 10rem;"></div>
</div>
<div slot="no-results"></div>
</infinite-loading>
</div>
</div>
</div>
</div>
</div>
<!-- <div class="col-12 col-md-4">
<div class="mt-5 media align-items-center">
<div class="mr-3">
<i class="fas fa-info-circle fa-2x text-lighter"></i>
</div>
<p class="media-body text-muted mb-0 font-weight-light">Groups are in beta, some <a href="#" class="font-weight-bold">limitations</a> apply.</p>
</div>
</div> -->
</div>
</div>
</template>
<script type="text/javascript">
import GroupStatus from './GroupStatus.vue';
export default {
props: {
profile: {
type: Object
}
},
data() {
return {
feed: [],
ids: [],
page: 1,
tab: 'feed',
initalLoad: false,
emptyFeed: true
};
},
components: {
'group-status': GroupStatus
},
mounted() {
this.fetchFeed();
},
methods: {
fetchFeed() {
axios.get('/api/v0/groups/self/feed', {
params: {
initial: true
}
})
.then(res => {
this.page++;
this.feed = res.data;
this.emptyFeed = this.feed.length === 0;
this.initalLoad = true;
})
},
infiniteFeed($state) {
if(this.feed.length < 2 || this.page > 5) {
$state.complete();
return;
}
axios.get('/api/v0/groups/self/feed', {
params: {
page: this.page
},
}).then(res => {
if (res.data.length) {
let data = res.data;
let self = this;
data.forEach(d => {
if(self.ids.indexOf(d.id) == -1) {
self.ids.push(d.id);
self.feed.push(d);
}
});
$state.loaded();
this.page++;
} else {
$state.complete();
}
});
},
switchTab(tab) {
this.tab = tab;
},
gotoDiscover() {
this.$emit('switchtab', 'discover');
}
}
}
</script>

View file

@ -0,0 +1,171 @@
<template>
<div class="my-groups-component">
<div class="list-container">
<div v-if="isLoaded">
<div class="list-group">
<a
v-for="(group, index) in groups" :key="'rec:'+group.id+':'+index"
class="list-group-item text-decoration-none"
:href="group.url">
<group-list-card
:group="group"
:truncateDescriptionLength="140"
:showStats="true" />
<!-- <div class="media align-items-center">
<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="mr-3 border rounded" style="width: 100px; height: 60px;object-fit: cover;padding:5px;">
<div v-else class="mr-3 border rounded" style="width: 100px; height: 60px;padding: 5px;">
<div class="bg-primary d-flex align-items-center justify-content-center" style="width: 100%; height:100%;">
<i class="fal fa-users text-white fa-lg"></i>
</div>
</div>
<div class="media-body">
<p class="h5 font-weight-bold mb-1 text-dark">
{{ group.name || 'Untitled Group' }}
</p>
<p class="text-muted small mb-1 read-more">
{{ truncate(group.description) }}
</p>
<p class="mb-0">
<span class="text-muted mr-2">
<i class="far fa-users"></i>
<strong class="small">{{ prettyCount(group.member_count) }}</strong>
</span>
<span class="member-label">
{{ group.self.role }}
</span>
<span v-if="!group.local" class="remote-label ml-2">
<i class="fal fa-globe"></i> Remote
</span>
</p>
</div>
</div> -->
</a>
</div>
<p v-if="canLoadMore">
<button
class="btn btn-primary btn-block font-weight-bold mt-3"
@click.prevent="loadMore"
:disabled="loadingMore">
Load more
</button>
</p>
</div>
<div v-else class="d-flex justify-content-center">
<b-spinner/>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import GroupListCard from './GroupListCard.vue';
export default {
props: {
profile: {
type: Object
}
},
components: {
"group-list-card": GroupListCard
},
data() {
return {
isLoaded: false,
groups: [],
canLoadMore: false,
loadingMore: false,
page: 1
}
},
mounted() {
this.fetchSelf();
},
methods: {
fetchSelf() {
axios.get('/api/v0/groups/self/list')
.then(res => {
let data = res.data.filter(g => {
return g.hasOwnProperty('id') && g.hasOwnProperty('url');
})
this.groups = data;
this.canLoadMore = res.data.length == 4;
this.page++;
this.isLoaded = true;
});
},
loadMore() {
this.loadingMore = true;
axios.get('/api/v0/groups/self/list', {
params: {
page: this.page
}
})
.then(res => {
let data = res.data.filter(g => {
return g.hasOwnProperty('id') && g.hasOwnProperty('url');
})
this.groups.push(...data);
this.canLoadMore = res.data.length == 4;
this.page++;
this.loadingMore = false;
});
},
prettyCount(val) {
return App.util.format.count(val);
},
truncate(str) {
if(str.length <= 140) {
return str;
}
return str.substr(0, 140) + ' ...';
}
}
}
</script>
<style lang="scss" scoped>
.my-groups-component {
.list-container {
margin-bottom: 30vh;
}
.member-label {
padding: 2px 5px;
font-size: 12px;
color: rgba(75, 119, 190, 1);
background:rgba(137, 196, 244, 0.2);
border:1px solid rgba(137, 196, 244, 0.3);
font-weight:400;
text-transform: capitalize;
border-radius: 3px;
}
.remote-label {
padding: 2px 5px;
font-size: 12px;
color: #4B5563;
background: #F3F4F6;
border:1px solid #E5E7EB;
font-weight:400;
text-transform: capitalize;
border-radius: 3px;
}
}
</style>

View file

@ -0,0 +1,41 @@
<template>
<div class="col-12 col-md-9" style="height: 100vh - 51px !important;overflow:hidden">
<div class="row h-100">
<div class="col-12 col-md-8 bg-lighter border-left">
<div class="p-4 mb-4">
<p class="h4 font-weight-bold mb-1 text-center">Group Invitations</p>
</div>
<div class="p-4 mb-4">
<p class="font-weight-bold text-center text-muted">You don't have any group invites</p>
</div>
</div>
<div class="col-12 col-md-4 bg-white border-left">
<div class="p-4">
<div class="bg-light rounded-lg border p-3">
<p class="lead font-weight-bold mb-0">Send Invite</p>
<p class="mb-3">Invite friends to your groups</p>
<div class="form-group" style="position: relative;">
<span style="position: absolute; top:50%;transform: translateY(-50%);left:15px;padding-right:5px;">
<i class="fas fa-search text-lighter"></i>
</span>
<input class="form-control bg-white rounded-pill" placeholder="Search username..." style="padding-left:40px">
</div>
</div>
</div>
<hr>
<div class="p-4 mb-2">
<p class="h4 font-weight-bold mb-1 text-center">Invitations Sent</p>
</div>
<div class="px-4 mb-4">
<p class="font-weight-bold text-center text-muted">You have not sent any group invites</p>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
}
</script>

View file

@ -0,0 +1,309 @@
<template>
<div class="group-notification-component col-12 col-md-9" style="height: 100vh - 51px !important;overflow:hidden">
<div class="row h-100 bg-white">
<div class="col-12 col-md-8 border-left">
<div class="px-5">
<div class="my-4">
<p class="h1 font-weight-bold mb-1">Group Notifications</p>
<!-- <p class="lead text-muted mb-0">Latest notifications from your groups</p> -->
</div>
<!-- <div class="p-4 mb-4">
<p class="font-weight-bold text-center text-muted">You don't have any notifications</p>
</div> -->
<div v-if="notifications.length > 0" v-for="(n, index) in notifications" class="nitem card card-body shadow-none mb-3 py-2 px-0 rounded-pill" style="background-color: #F3F4F6">
<div class="media align-items-center px-3">
<img class="mr-3 rounded-circle" style="border:1px solid #ccc" :src="n.account.avatar" alt="" width="32px" height="32px">
<div class="media-body">
<div v-if="n.type == 'group:like'">
<p class="my-0">
<a :href="getProfileUrl(n.account)" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> liked your <a v-bind:href="getPostUrl(n.status)">post</a> in <a :href="n.group.url">{{n.group.name}}</a>
</p>
</div>
<div v-else-if="n.type == 'group:comment'">
<p class="my-0">
<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.username">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="n.status.url">post</a> in <a :href="n.group.url">{{n.group.name}}</a>
</p>
</div>
<div v-else-if="n.type == 'mention'">
<p class="my-0">
<a :href="getProfileUrl(n.account)" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> <a v-bind:href="mentionUrl(n.status)">mentioned</a> you.
</p>
</div>
<div v-else-if="n.type == 'group.join.approved'">
<p class="my-0">
Your application to join <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> was approved!
</p>
</div>
<div v-else-if="n.type == 'group.join.rejected'">
<p class="my-0">
Your application to join <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> was rejected. You can re-apply to join in 6 months.
</p>
</div>
<div v-else>
<p class="my-0">Cannot display notification</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="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>
<a v-if="viewContext(n) != '/'" class="btn btn-outline-primary py-0 font-weight-bold" :href="viewContext(n)">View</a>
</div> -->
<div class="align-items-center text-muted">
<span class="small" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</span>
<span>·</span>
<div class="dropdown d-inline">
<a class="dropdown-toggle text-lighter" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="far fa-cog fa-sm"></i>
</a>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item font-weight-bold" href="#">Dismiss</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item font-weight-bold" href="#">Help</a>
<!-- <a class="dropdown-item font-weight-bold" href="#">Ignore</a> -->
<a class="dropdown-item font-weight-bold" href="#">Report</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4 border-left bg-light">
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
data() {
return {
notifications: [],
initialLoad: false,
loading: true,
page: 1
}
},
mounted() {
this.fetchNotifications();
},
methods: {
fetchNotifications() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
window._sharedData.curUser = res.data;
window.App.util.navatar();
});
axios.get('/api/v0/groups/self/notifications')
.then(res => {
let data = res.data.filter(n => {
if(n.type == 'share' && !n.status) {
return false;
}
if(n.type == 'comment' && !n.status) {
return false;
}
if(n.type == 'mention' && !n.status) {
return false;
}
if(n.type == 'favourite' && !n.status) {
return false;
}
if(n.type == 'follow' && !n.account) {
return false;
}
return true;
});
// let ids = res.data.map(n => n.id);
// this.notificationMaxId = Math.max(...ids);
this.notifications = data;
// $('.notification-card .loader').addClass('d-none');
// $('.notification-card .contents').removeClass('d-none');
});
},
// infiniteNotifications($state) {
// if(this.notificationCursor > 10) {
// $state.complete();
// return;
// }
// axios.get('/api/pixelfed/v1/notifications', {
// params: {
// max_id: this.notificationMaxId
// }
// }).then(res => {
// if(res.data.length) {
// let data = res.data.filter(n => {
// if(n.type == 'share' && !n.status) {
// return false;
// }
// if(n.type == 'comment' && !n.status) {
// return false;
// }
// if(n.type == 'mention' && !n.status) {
// return false;
// }
// if(n.type == 'favourite' && !n.status) {
// return false;
// }
// if(n.type == 'follow' && !n.account) {
// return false;
// }
// if(_.find(this.notifications, {id: n.id})) {
// return false;
// }
// return true;
// });
// this.notifications.push(...data);
// this.notificationCursor++;
// $state.loaded();
// } else {
// $state.complete();
// }
// });
// },
truncate(text) {
if(text.length <= 15) {
return text;
}
return text.slice(0,15) + '...'
},
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;
},
followProfile(n) {
let self = this;
let id = n.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
self.notifications.map(notification => {
if(notification.account.id === id) {
notification.relationship.following = true;
}
});
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
viewContext(n) {
switch(n.type) {
case 'follow':
return n.account.url;
break;
case 'mention':
return n.status.url;
break;
case 'like':
case 'favourite':
case 'comment':
return n.status.url;
break;
case 'tagged':
return n.tagged.post_url;
break;
case 'direct':
return '/account/direct/t/'+n.account.id;
break
}
return '/';
},
getProfileUrl(account) {
if(account.local == true) {
return account.url;
}
return '/i/web/profile/_/' + account.id;
},
getPostUrl(status) {
if(status.local == true) {
return status.url;
}
return '/i/web/post/_/' + status.account.id + '/' + status.id;
}
}
}
</script>
<style lang="scss">
.group-notification-component {
.dropdown-toggle::after {
content: '';
display: none;
}
.nitem {
a {
color: #000;
font-weight: 700 !important;
&:hover,
&:focus {
color: #121416 !important;
}
}
}
}
</style>

View file

@ -0,0 +1,47 @@
<template>
<div class="col-12 col-md-9" style="height: 100vh - 51px !important;overflow:hidden">
<div class="row h-100 bg-lighter">
<div class="col-12 col-md-8 border-left">
<div class="d-flex justify-content-center">
<div class="p-4 mb-4">
<p class="h4 font-weight-bold mb-1">Find a Remote Group</p>
<p class="lead text-muted">Search and explore remote federated groups.</p>
</div>
</div>
<div class="mb-5">
<div class="p-4 mb-4">
<div class="form-group">
<label>Group URL</label>
<input type="text" class="form-control form-control-lg rounded-pill bg-white border" placeholder="https://pixelfed.social/groups/328323406233735168" v-model="q">
</div>
<button class="btn btn-primary btn-block btn-lg rounded-pill font-weight-bold">Search</button>
</div>
</div>
</div>
<div class="col-12 col-md-4 bg-white border-left">
<div class="my-4">
<h4 class="font-weight-bold">Tips</h4>
<ul class="pl-3">
<li class="font-weight-bold">Some remote groups are not supported*</li>
<li>Read and comply with group rules defined by group admins</li>
<li>Use the full <span class="font-weight-bold">Group URL</span> including <code>https://</code></li>
<li>Joining private groups requires manual approval from group admins, you will recieve a notification when your membership is approved</li>
<li>Inviting people to remote groups is not supported yet</li>
<li>Your group membership may be terminated at any time by group admins</li>
</ul>
<p class="small">* Some remote groups may not be compatible, we are working to support other group implementations</p>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
data() {
return {
q: undefined
}
}
}
</script>

View file

@ -0,0 +1,11 @@
<template>
<div class="share-menu-component">
</div>
</template>
<script type="text/javascript">
export default {
}
</script>

View file

@ -0,0 +1,304 @@
<template>
<div class="group-post-header media">
<div v-if="showGroupHeader" style="position: relative;" class="mb-1">
<img
v-if="group.hasOwnProperty('metadata') && (group.metadata.hasOwnProperty('avatar') || group.metadata.hasOwnProperty('header'))"
class="rounded-lg box-shadow mr-2"
:src="group.metadata.hasOwnProperty('header') ? group.metadata.header.url : group.metadata.avatar.url"
width="52"
height="52"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
alt="avatar"
style="object-fit:cover;"
/>
<span
v-else
class="d-block rounded-lg box-shadow mr-2 bg-primary"
style="width: 52px;height:52px;"
></span>
<img
class="rounded-circle box-shadow border mr-2"
:src="status.account.avatar"
width="36"
height="36"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
alt="avatar"
style="position: absolute; bottom:-4px; right:-4px;"
/>
</div>
<img
v-else
class="rounded-circle box-shadow mr-2"
:src="status.account.avatar"
width="42"
height="42"
onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
alt="avatar"
/>
<div class="media-body">
<div class="pl-2 d-flex align-items-top">
<div>
<p class="mb-0">
<router-link
v-if="showGroupHeader && group"
:to="`/groups/${status.gid}`"
class="group-name-link username"
>
{{ group.name }}
</router-link>
<router-link
v-else
:to="`/groups/${status.gid}/user/${status?.account.id}`"
class="group-name-link username"
v-html="statusCardUsernameFormat(status)"
>
Loading...
</router-link>
<span v-if="showGroupChevron">
<span class="text-muted" style="padding-left:2px;padding-right:2px;">
<i class="fas fa-caret-right"></i>
</span>
<span>
<router-link
:to="`/groups/${status.gid}`"
class="group-name-link"
>
{{ group.name }}
</router-link>
</span>
</span>
</p>
<p class="mb-0 mt-n1">
<span
v-if="showGroupHeader && group"
style="font-size:13px"
>
<router-link
:to="`/groups/${status.gid}/user/${status?.account.id}`"
class="group-name-link-small username"
v-html="statusCardUsernameFormat(status)"
>
Loading...
</router-link>
<span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
<router-link
:to="`/groups/${status.gid}/p/${status.id}`"
class="font-weight-light text-muted"
>
{{shortTimestamp(status.created_at)}}
</router-link>
<span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
<span class="text-muted"><i class="fas fa-globe"></i></span>
</span>
<span v-else>
<router-link
:to="`/groups/${status.gid}/p/${status.id}`"
class="font-weight-light text-muted small"
>
{{shortTimestamp(status.created_at)}}
</router-link>
<span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
<span class="text-muted small"><i class="fas fa-globe"></i></span>
</span>
</p>
</div>
<div v-if="profile" class="text-right" style="flex-grow:1;">
<div class="dropdown">
<button class="btn btn-link dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
<span class="fas fa-ellipsis-h text-lighter"></span>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="#">View Post</a>
<a class="dropdown-item" href="#">View Profile</a>
<a class="dropdown-item" href="#">Copy Link</a>
<a class="dropdown-item" href="#" @click.prevent="sendReport()">Report</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger" href="#">Delete</a>
</div>
</div>
<!-- <button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
<span class="fas fa-ellipsis-h text-lighter"></span>
<span class="sr-only">Post Menu</span>
</button> -->
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
group: {
type: Object
},
status: {
type: Object
},
profile: {
type: Object
},
showGroupHeader: {
type: Boolean,
default: false
},
showGroupChevron: {
type: Boolean,
default: false
}
},
data() {
return {
reportTypes: [
{ key: "spam", title: "It's spam" },
{ key: "sensitive", title: "Nudity or sexual activity" },
{ key: "abusive", title: "Bullying or harassment" },
{ key: "underage", title: "I think this account is underage" },
{ key: "violence", title: "Violence or dangerous organizations" },
{ key: "copyright", title: "Copyright infringement" },
{ key: "impersonation", title: "Impersonation" },
{ key: "scam", title: "Scam or fraud" },
{ key: "terrorism", title: "Terrorism or terrorism-related content" }
]
}
},
methods: {
formatCount(count) {
return App.util.format.count(count);
},
statusUrl(status) {
return '/groups/' + status.gid + '/p/' + status.id;
},
profileUrl(status) {
return '/groups/' + status.gid + '/user/' + status.account.id;
},
timestampFormat(timestamp) {
let ts = new Date(timestamp);
return ts.toDateString() + ' ' + ts.toLocaleTimeString();
},
shortTimestamp(ts) {
return window.App.util.format.timeAgo(ts);
},
statusCardUsernameFormat(status) {
if(status.account.local == true) {
return status.account.username;
}
let fmt = window.App.config.username.remote.format;
let txt = window.App.config.username.remote.custom;
let usr = status.account.username;
let dom = document.createElement('a');
dom.href = status.account.url;
dom = dom.hostname;
switch(fmt) {
case '@':
return usr + '<span class="text-lighter font-weight-bold">@' + dom + '</span>';
break;
case 'from':
return usr + '<span class="text-lighter font-weight-bold"> <span class="font-weight-normal">from</span> ' + dom + '</span>';
break;
case 'custom':
return usr + '<span class="text-lighter font-weight-bold"> ' + txt + ' ' + dom + '</span>';
break;
default:
return usr + '<span class="text-lighter font-weight-bold">@' + dom + '</span>';
break;
}
},
sendReport(type) {
let el = document.createElement('div');
el.classList.add('list-group');
this.reportTypes.forEach(rt => {
let button = document.createElement('button');
button.classList.add('list-group-item', 'small');
button.innerHTML = rt.title;
button.onclick = () => {
document.dispatchEvent(new CustomEvent('reportOption', {
detail: { key: rt.key, title: rt.title }
}));
};
el.appendChild(button);
});
let wrapper = document.createElement('div');
wrapper.appendChild(el);
swal({
title: "Report Content",
icon: "warning",
content: wrapper,
buttons: false
});
document.addEventListener('reportOption', (event) => {
console.log(event.detail);
this.showConfirmation(event.detail);
}, { once: true });
},
showConfirmation(option) {
console.log(option)
swal({
title: "Confirmation",
text: `You selected ${option.title}. Do you want to proceed?`,
icon: "info",
buttons: true
}).then((confirm) => {
if (confirm) {
axios.post(`/api/v0/groups/${this.status.gid}/report/create`, {
'type': option.key,
'id': this.status.id,
}).then(res => {
swal("Confirmed!", "Your report has been submitted.", "success");
})
} else {
swal("Cancelled", "Your report was not submitted.", "error");
}
});
}
}
}
</script>
<style lang="scss" scoped>
.group-post-header {
.btn::focus {
box-shadow: none;
}
.dropdown-toggle::after {
display: none;
}
.group-name-link {
color: var(--body-color) !important;
word-break: break-word !important;
word-wrap: break-word !important;
text-decoration: none;
font-size: 16px;
font-weight: 600;
}
.group-name-link-small {
color: var(--body-color) !important;
word-break: break-word !important;
word-wrap: break-word !important;
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
}
</style>

View file

@ -0,0 +1,58 @@
<template>
<div class="card shadow-sm" style="border-radius: 18px !important;">
<div class="card-body pb-0">
<div class="card card-body border shadow-none mb-3" style="background-color: #E5E7EB;">
<div class="media p-md-4">
<div class="mr-4 pt-2">
<i class="fas fa-lock fa-2x"></i>
</div>
<div class="media-body" style="max-width: 320px">
<p class="lead font-weight-bold mb-1">This content isn't available right now</p>
<p class="mb-0" style="font-size: 12px;letter-spacing:-0.3px;">When this happens, it's usually because the owner only shared it with a small group of people, changed who can see it, or it's been deleted.</p>
</div>
</div>
</div>
<div>
<comment-drawer
v-if="showCommentDrawer"
:permalink-mode="permalinkMode"
:permalink-status="childContext"
:status="status"
:profile="profile"
:group-id="groupId"
:can-reply="false" />
</div>
</div>
</div>
</template>
<script>
import CommentDrawer from '@/groups/partials/CommentDrawer.vue';
export default {
props: {
showCommentDrawer: {
type: Boolean,
},
permalinkMode: {
type: Boolean,
},
childContext: {
type: Object
},
status: {
type: Object
},
profile: {
type: Object
},
groupId: {
type: String
},
},
components: {
"comment-drawer": CommentDrawer,
}
}
</script>

View file

@ -0,0 +1,23 @@
<template>
<div class="w-100 h-100">
<div v-if="!loaded" class="d-flex w-100 h-100 py-5 justify-content-center align-items-center">
<div class="text-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
<p class="text-center font-weight-bold mt-1">Loading...</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
loaded: {
type: Boolean,
default: false
}
}
}
</script>

View file

@ -0,0 +1,316 @@
<template>
<div class="col-3 shadow groups-sidenav">
<div class="p-1">
<div class="d-flex justify-content-between align-items-center py-3">
<p class="h2 font-weight-bold mb-0">Groups</p>
<a class="btn btn-light px-2 rounded-circle" href="/settings/home">
<i class="fas fa-cog fa-lg"></i>
</a>
</div>
<div class="mb-3">
<autocomplete
:search="autocompleteSearch"
placeholder="Search groups by name"
aria-label="Search groups by name"
:get-result-value="getSearchResultValue"
:debounceTime="700"
@submit="onSearchSubmit"
ref="autocomplete"
>
<template #result="{ result, props }">
<li
v-bind="props"
class="autocomplete-result"
>
<div class="media align-items-center">
<img v-if="result.local && result.metadata && result.metadata.hasOwnProperty('header') && result.metadata.header.hasOwnProperty('url')" :src="result.metadata.header.url" width="32" height="32">
<div v-else class="icon-placeholder">
<i class="fal fa-user-friends"></i>
</div>
<div class="media-body text-truncate mr-3">
<p class="result-name mb-n1 font-weight-bold">
{{ truncateName(result.name) }}
<span v-if="result.verified" class="fa-stack ml-n2" title="Verified Group" data-toggle="tooltip">
<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
</span>
</p>
<p class="mb-0 text-muted" style="font-size: 10px;">
<span v-if="!result.local" title="Remote Group">
<i class="far fa-globe"></i>
</span>
<span v-if="!result.local">·</span>
<span class="font-weight-bold">{{ result.member_count }} members</span>
</p>
</div>
</div>
</li>
</template>
</autocomplete>
</div>
<!-- <button
class="btn btn-light group-nav-btn"
:class="{ active: tab == 'feed' }"
@click="switchTab('feed')">
<div class="group-nav-btn-icon">
<i class="fas fa-list"></i>
</div>
<div class="group-nav-btn-name">
Your Feed
</div>
</button> -->
<template v-for="tab in tabs">
<router-link
class="btn btn-light group-nav-btn"
:to="tab.path">
<div class="group-nav-btn-icon">
<i :class="tab.icon"></i>
</div>
<div class="group-nav-btn-name">
{{ tab.name }}
</div>
</router-link>
</template>
<router-link
to="/groups/create"
class="btn btn-primary btn-block rounded-pill font-weight-bold mt-3">
<i class="fas fa-plus mr-2"></i> Create New Group
</router-link>
<hr>
<!-- <div v-for="group in groups" class="ml-2">
<div class="card shadow-sm border text-decoration-none text-dark">
<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: auto;object-fit: cover;max-height: 160px;">
<div v-else class="bg-primary" style="width:100%;height:160px;"></div>
<div class="card-body">
<div class="lead font-weight-bold d-flex align-items-top" style="height: 60px;">
{{ group.name }}
<span v-if="group.verified" class="fa-stack ml-n2 mt-n2">
<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
</span>
</div>
<div class="text-muted font-weight-light d-flex justify-content-between">
<span>{{group.member_count}} Members</span>
<span style="font-size: 12px;padding: 2px 5px;color: rgba(75, 119, 190, 1);background:rgba(137, 196, 244, 0.2);border:1px solid rgba(137, 196, 244, 0.3);font-weight:400;text-transform: capitalize;" class="rounded">{{ group.self.role }}</span>
</div>
<hr>
<p class="mb-0">
<a class="btn btn-light btn-block border rounded-lg font-weight-bold" :href="group.url">View Group</a>
</p>
</div>
</div>
</div> -->
</div>
</div>
</template>
<script>
import Autocomplete from '@trevoreyre/autocomplete-vue'
import '@trevoreyre/autocomplete-vue/dist/style.css'
export default {
data() {
return {
initialLoad: false,
tabs: [
{ name: 'Your Feed', icon: 'fas fa-list', path: '/groups/feed' },
{ name: 'Discover', icon: 'fas fa-compass', path: '/groups/discover' },
{ name: 'Your Groups', icon: 'fas fa-list', path: '/groups/joins' },
// { name: 'Notifications', icon: 'far fa-bell', path: '/groups/notifications' },
// { name: 'Find Remote Group', icon: 'far fa-search-plus', path: '/groups/search' },
],
config: {},
groups: [],
profile: {},
tab: null,
searchQuery: undefined,
};
},
components: {
'autocomplete': Autocomplete,
},
methods: {
autocompleteSearch(input) {
if (!input || input.length < 2) {
return [];
};
this.searchQuery = input;
// this.tab = 'searchresults';
if(input.startsWith('http')) {
let url = new URL(input);
if(url.hostname == location.hostname) {
location.href = input;
return [];
}
return [];
}
if(input.startsWith('#')) {
this.$bvToast.toast(input, {
title: 'Hashtag detected',
variant: 'info',
autoHideDelay: 5000
});
return [];
}
return axios.post('/api/v0/groups/search/global', {
q: input,
v: '0.2'
})
.then(res => {
this.searchLoading = false;
return res.data;
}).catch(err => {
if(err.response.status === 422) {
this.$bvToast.toast(err.response.data.error.message, {
title: 'Cannot display search results',
variant: 'danger',
autoHideDelay: 5000
});
}
return [];
})
},
getSearchResultValue(result) {
return result.name;
},
onSearchSubmit(result) {
if (result.length < 1) {
return [];
}
location.href = result.url;
},
truncateName(val) {
if(val.length < 24) {
return val;
}
return val.substr(0, 23) + '...';
}
}
}
</script>
<style lang="scss">
.groups-sidenav {
display: none;
font-family: var(--font-family-sans-serif);
@media(min-width: 768px) {
display: block;
width: 100%;
height: 100vh;
background: #fff;
top: 74px;
border: none;
overflow: hidden;
z-index: 1;
position: sticky;
}
.group-nav-btn {
display: block;
width: 100%;
padding-left: 0;
padding-top: 0.3rem;
padding-bottom: 0.3rem;
margin-bottom: 0.3rem;
border-radius: 1.5rem;
text-align: left;
color: #6c757d;
background-color: transparent;
border-color: transparent;
justify-content: flex-start;
&.active {
background-color: #EFF6FF !important;
border:1px solid #DBEAFE !important;
color: #212529;
.group-nav-btn-icon {
background-color: #2c78bf !important;
color: #fff !important;
}
}
&-icon {
display: inline-flex;
width: 35px;
height: 35px;
padding: 12px;
background-color: #E5E7EB;
border-radius: 17px;
margin: auto 0.3rem;
align-items: center;
justify-content: center;
}
&-name {
display: inline-block;
margin-left: 0.3rem;
font-weight: 700;
}
}
.autocomplete-input {
height: 2.375rem;
background-color: #f8f9fa !important;
font-size: 0.9rem;
color: #495057;
border-radius: 50rem;
border-color: transparent;
&:focus,
&[aria-expanded=true] {
box-shadow: none;
}
}
.autocomplete-result {
background: none;
padding: 12px;
&:hover,
&:focus {
background-color: #EFF6FF !important;
}
.media {
img {
object-fit: cover;
border-radius: 4px;
margin-right: 0.6rem;
}
.icon-placeholder {
display: flex;
width: 32px;
height: 32px;
background-color: #2c78bf;
border-radius: 4px;
justify-content: center;
align-items: center;
color: #fff;
margin-right: 0.6rem;
}
}
}
}
</style>

4
resources/assets/js/group-status.js vendored Normal file
View file

@ -0,0 +1,4 @@
Vue.component(
'gs-permalink',
require('./components/GroupStatusPermalink.vue').default
);

View file

@ -0,0 +1,4 @@
Vue.component(
'group-topic-feed',
require('./../components/groups/GroupTopicFeed.vue').default
);

29
resources/assets/js/groups.js vendored Normal file
View file

@ -0,0 +1,29 @@
Vue.component(
'group-component',
require('./../components/Groups.vue').default
);
Vue.component(
'groups-home',
require('./../components/groups/GroupsHome.vue').default
);
Vue.component(
'group-feed',
require('./../components/groups/GroupFeed.vue').default
);
Vue.component(
'group-settings',
require('./../components/groups/GroupSettings.vue').default
);
Vue.component(
'group-profile',
require('./../components/groups/GroupProfile.vue').default
);
Vue.component(
'groups-invite',
require('./../components/groups/GroupInvite.vue').default
);

3
webpack.mix.js vendored
View file

@ -40,6 +40,9 @@ mix.js('resources/assets/js/app.js', 'public/js')
.js('resources/assets/js/admin_invite.js', 'public/js')
.js('resources/assets/js/landing.js', 'public/js')
.js('resources/assets/js/remote_auth.js', 'public/js')
.js('resources/assets/js/groups.js', 'public/js')
.js('resources/assets/js/group-status.js', 'public/js')
.js('resources/assets/js/group-topic-feed.js', 'public/js')
.vue({ version: 2 });
mix.extract();