habitica-self-host/website/client/src/components/groups/membersModal.vue
2025-06-11 19:20:11 -05:00

712 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- @TODO: Move this to a member directory-->
<div>
<remove-member-modal
:member-to-remove="memberToRemove"
:group-id="groupId"
@member-removed="memberRemoved"
/>
<b-modal
id="members-modal"
:title="$t('createGuild')"
size="md"
:hide-footer="true"
>
<div
slot="modal-header"
class="header-wrap"
>
<div class="row">
<div class="col-6">
<h1 v-once>
{{ $t('members') }}
</h1>
</div>
<div class="col-6">
<button
class="close"
type="button"
aria-label="Close"
@click="close()"
>
<span aria-hidden="true">×</span>
</button>
</div>
</div>
<div class="row d-flex align-items-center">
<div class="col-4">
<input
v-model="searchTerm"
class="form-control input-search"
type="text"
:placeholder="$t('search')"
>
</div>
<div class="col-5">
<select-list
:items="sortOptions"
:hide-icon="false"
:inline-dropdown="false"
:direct-select="true"
:value="optionEntryBySelectedValue"
key-prop="value"
@select="changeSortOption($event)"
>
<template #item="{ item }">
<span
v-if="item"
class="label"
>{{ item.text }}</span>
</template>
</select-list>
</div>
<div class="col-3">
<select-list
:items="sortDirections"
:value="directionEntryBySelectedValue"
:hide-icon="false"
:inline-dropdown="false"
:direct-select="true"
key-prop="value"
@select="changeSortDirection($event)"
>
<template #item="{ item }">
<span
v-if="item"
class="label"
>{{ item.text }}</span>
</template>
</select-list>
</div>
</div>
</div>
<div
v-if="sortDirty && group.type === 'party'"
class="row apply-options d-flex justify-content-center"
>
<a @click="applySortOptions()">{{ $t('applySortToHeader') }}</a>
</div>
<div
v-if="invites.length > 0"
class="row"
>
<div class="col-6 offset-3 nav mt-2 mb-3">
<div
class="nav-item"
:class="{active: selectedPage === 'members'}"
@click="viewMembers()"
>
{{ $t('members') }}
</div>
<div
class="nav-item"
:class="{active: selectedPage === 'invites'}"
@click="viewInvites()"
>
{{ $t('invites') }}
</div>
</div>
</div>
<loading-gryphon v-if="loading" />
<div
v-if="selectedPage === 'members' && !loading"
:class="{'mt-1': invites.length === 0}"
>
<div
v-for="(member, index) in sortedMembers"
:key="member._id"
class="row"
>
<div class="col-11 pl-0">
<member-details
:member="member"
:class-badge-position="'next-to-name'"
class="ml-4"
/>
</div>
<div class="col-1 actions">
<b-dropdown right="right">
<div
slot="button-content"
class="svg-icon inline dots pt-1"
v-html="icons.dots"
></div>
<b-dropdown-item @click="sendMessage(member)">
<span class="dropdown-icon-item">
<div
class="svg-icon inline"
v-html="icons.messageIcon"
></div>
<span class="text">{{ $t('sendMessage') }}</span>
</span>
</b-dropdown-item>
<b-dropdown-item
v-if="shouldShowLeaderFunctions(member._id)"
@click="promoteToLeader(member)"
>
<span class="dropdown-icon-item">
<div
class="svg-icon inline"
v-html="icons.starIcon"
></div>
<span class="text">{{ $t('promoteToLeader') }}</span>
</span>
</b-dropdown-item>
<b-dropdown-item
v-if="shouldShowAddManager(member._id)"
@click="addManager(member._id)"
>
<span class="dropdown-icon-item">
<div
class="svg-icon inline"
v-html="icons.starIcon"
></div>
<span class="text">{{ $t('addManager') }}</span>
</span>
</b-dropdown-item>
<b-dropdown-item
v-if="shouldShowRemoveManager(member._id)"
@click="removeManager(member._id)"
>
<span class="dropdown-icon-item">
<div
class="svg-icon inline block-icon"
v-html="icons.blockIcon"
></div>
<span class="text">{{ $t('removeManager2') }}</span>
</span>
</b-dropdown-item>
<b-dropdown-item
v-if="challengeId"
@click="viewProgress(member)"
>
<span class="dropdown-icon-item">
<span class="text">{{ $t('viewProgress') }}</span>
</span>
</b-dropdown-item>
<b-dropdown-item
v-if="shouldShowLeaderFunctions(member._id)"
@click="removeMember(member, index)"
>
<span class="dropdown-icon-item">
<div
class="svg-icon inline block-icon"
v-html="icons.blockIcon"
></div>
<span class="text">{{ $t('removeMember') }}</span>
</span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
<div
v-if="isLoadMoreAvailable"
class="row"
>
<div class="col-12 text-center">
<button
class="btn btn-secondary"
@click="loadMoreMembers()"
>
{{ $t('loadMore') }}
</button>
</div>
</div>
<div
v-if="members.length > 3"
class="row gradient"
></div>
</div>
<div v-if="selectedPage === 'invites' && !loading">
<div
v-for="(member, index) in invites"
:key="member._id"
class="row"
>
<div class="col-11 pl-0">
<member-details :member="member" />
</div>
<div class="col-1 actions">
<b-dropdown right="right">
<div
slot="button-content"
class="svg-icon inline dots"
v-html="icons.dots"
></div>
<b-dropdown-item
v-if="isLeader"
@click="removeInvite(member, index)"
>
<span class="dropdown-icon-item">
<div
v-if="isLeader"
class="svg-icon inline block-icon"
v-html="icons.blockIcon"
></div>
<span class="text">{{ $t('removeInvite') }}</span>
</span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
</b-modal>
</div>
</template>
<style lang='scss'>
#members-modal {
.modal-header {
background-color: #edecee;
border-radius: 8px 8px 0 0;
box-shadow: 0 1px 2px 0 rgba(26, 24, 29, 0.24);
}
.small-text, .character-name {
color: #878190;
}
.modal-body {
padding-left: 0;
padding-right: 0;
padding-bottom: 0;
padding-top: 0;
}
.member-details {
margin: 0;
}
.actions .dropdown-toggle::after {
content: none !important;
}
}
</style>
<style lang='scss' scoped>
@import '@/assets/scss/colors.scss';
.apply-options {
padding: 1em;
margin: 0;
background-color: #f9f9f9;
color: #2995cd;
}
.header-wrap {
width: 100%;
}
.form-control {
font-size: 0.9rem;
}
h1 {
color: #4f2a93;
}
.actions {
.b-dropdown {
position: absolute;
right: 24px;
top: 8px;
}
.dots {
height: 16px;
width: 4px;
}
}
#members-modal_modal_body {
padding: 0;
max-height: 450px;
.col-8 {
margin-left: 0;
}
.member-details {
margin: 0;
}
.member-stats {
width: 382px;
height: 147px;
}
.gradient {
background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0), #ffffff);
height: 50px;
width: 100%;
position: absolute;
bottom: 0px;
margin-left: -15px;
}
}
.dropdown-icon-item .svg-icon {
width: 20px;
}
.nav {
font-weight: bold;
}
.nav-item {
display: inline-block;
font-size: 16px;
margin: 0 auto;
padding: .5em;
color: #878190;
}
.nav-item:hover, .nav-item.active {
color: #4f2a93;
border-bottom: 2px solid #4f2a93;
cursor: pointer;
}
.block-icon {
color: $gray-200;
}
</style>
<script>
import orderBy from 'lodash/orderBy';
import isEmpty from 'lodash/isEmpty';
import removeMemberModal from '@/components/members/removeMemberModal';
import loadingGryphon from '@/components/ui/loadingGryphon';
import MemberDetails from '../memberDetails';
import blockIcon from '@/assets/svg/block.svg?raw';
import messageIcon from '@/assets/members/message.svg?raw';
import starIcon from '@/assets/members/star.svg?raw';
import dots from '@/assets/svg/dots.svg?raw';
import SelectList from '@/components/ui/selectList';
import { PAGES } from '@/libs/consts';
import { userStateMixin } from '../../mixins/userState';
export default {
components: {
SelectList,
MemberDetails,
removeMemberModal,
loadingGryphon,
},
mixins: [userStateMixin],
props: ['hideBadge'],
data () {
return {
sortOption: {
// default sort options
value: 'stats.class',
direction: 'asc',
},
sortDirty: false,
selectedPage: 'members',
members: [],
invites: [],
memberToRemove: {},
sortOptions: [
{
value: 'stats.class',
text: this.$t('sortClass'),
},
{
value: 'preferences.background',
text: this.$t('sortBackground'),
},
{
value: 'auth.timestamps.created',
text: this.$t('sortDateJoined'),
},
{
value: 'auth.timestamps.loggedin',
text: this.$t('sortLogin'),
},
{
value: 'stats.lvl',
text: this.$t('sortLevel'),
},
{
value: 'profile.name',
text: this.$t('sortName'),
},
{
value: 'contributor.level',
text: this.$t('sortTier'),
},
],
sortDirections: [
{
value: 'asc',
text: this.$t('ascendingAbbrev'),
},
{
value: 'desc',
text: this.$t('descendingAbbrev'),
},
],
searchTerm: '',
icons: Object.freeze({
blockIcon,
messageIcon,
starIcon,
dots,
}),
userIdToMessage: '',
};
},
computed: {
isLeader () {
if (!this.group || !this.group.leader) return false;
return this.user._id === this.group.leader || this.user._id === this.group.leader._id;
},
isAdmin () {
return Boolean(this.hasPermission(this.user, 'moderator'));
},
isLoadMoreAvailable () {
// Only available if the current length of `members` is less than the
// total size of the Group/Challenge
return this.members.length < this.$store.state.memberModalOptions.memberCount;
},
groupIsSubscribed () {
return this.group.purchased && this.group.purchased.active;
},
group () {
return this.$store.state.memberModalOptions.group;
},
groupId () {
return this.$store.state.memberModalOptions.groupId || this.group._id;
},
challengeId () {
return this.$store.state.memberModalOptions.challengeId;
},
loading () {
return this.$store.state.memberModalOptions.loading;
},
sortedMembers () {
let sortedMembers = this.members.slice(); // shallow clone to avoid infinite loop
if (!isEmpty(this.sortOption)) {
// Use the memberlist filtered by searchTerm
if (this.sortOption.value === 'profile.name') {
// If members are to be sorted by name, use localeCompare for case-
// insensitive sort
sortedMembers.sort(
(a, b) => {
if (this.sortOption.direction === 'desc') {
return b.profile.name.localeCompare(a.profile.name);
}
return a.profile.name.localeCompare(b.profile.name);
},
);
} else {
sortedMembers = orderBy(
sortedMembers,
[this.sortOption.value],
[this.sortOption.direction],
);
}
}
return sortedMembers;
},
optionEntryBySelectedValue () {
return this.sortOptions.find(o => o.value === this.sortOption.value);
},
directionEntryBySelectedValue () {
return this.sortDirections.find(o => o.value === this.sortOption.direction);
},
},
watch: {
// Watches `searchTerm` and if present, performs a `searchMembers` action
// and usual `getMembers` otherwise
searchTerm () {
if (this.searchTerm) {
this.searchMembers(this.searchTerm);
} else {
this.getMembers();
}
},
},
mounted () {
this.$root.$on('habitica:show-member-modal', data => {
// @TODO: Remove store
this.$store.state.memberModalOptions.challengeId = data.challengeId;
this.$store.state.memberModalOptions.groupId = data.groupId;
this.$store.state.memberModalOptions.group = data.group;
this.$store.state.memberModalOptions.memberCount = data.memberCount;
this.$store.state.memberModalOptions.viewingMembers = data.viewingMembers;
this.$store.state.memberModalOptions.fetchMoreMembers = data.fetchMoreMembers;
this.$root.$emit('bv::show::modal', 'members-modal');
this.getMembers();
});
},
beforeDestroy () {
this.$root.$off('habitica:show-member-modal');
},
methods: {
sendMessage (member) {
this.$store.dispatch('user:newPrivateMessageTo', {
member,
});
this.$root.$emit('bv::hide::modal', 'members-modal');
this.$router.push(PAGES.PRIVATE_MESSAGES);
},
async searchMembers (searchTerm = '') {
this.members = await this.$store.state.memberModalOptions.fetchMoreMembers({
challengeId: this.challengeId,
groupId: this.groupId,
searchTerm,
includeAllPublicFields: true,
});
},
async getMembers () {
this.members = this.$store.state.memberModalOptions.viewingMembers;
await this.searchMembers('');
const { groupId } = this;
if (groupId && groupId !== 'challenge') {
const invites = await this.$store.dispatch('members:getGroupInvites', {
groupId,
includeAllPublicFields: true,
});
if (this.selectedPage === 'invites' && invites.length === 0) this.viewMembers();
this.invites = invites;
}
},
async clickMember (uid, forceShow) {
const user = this.$store.state.user.data;
if (user._id === uid && !forceShow) {
if (this.$route.name === 'tasks') {
this.$route.router.go('options.profile.avatar');
return;
}
this.$route.router.go('tasks');
return;
}
await this.$store.dispatch('members:selectMember', {
memberId: uid,
});
this.$root.$emit('bv::show::modal', 'members-modal');
},
async removeMember (member, index) {
this.memberToRemove = member;
this.memberToRemove.index = index;
this.$root.$emit('bv::show::modal', 'remove-member');
},
memberRemoved () {
this.members.splice(this.memberToRemove.index, 1);
this.group.memberCount -= 1;
this.memberToRemove = {};
},
async quickReply (uid) {
this.memberToReply = uid;
await this.$store.dispatch('members:selectMember', {
memberId: uid,
});
this.$root.$emit('bv::show::modal', 'private-message'); // MemberModalCtrl
},
async addManager (memberId) {
await this.$store.dispatch('guilds:addManager', {
groupId: this.groupId,
memberId,
});
window.alert(this.$t('managerAdded')); // eslint-disable-line no-alert
},
async removeManager (memberId) {
await this.$store.dispatch('guilds:removeManager', {
groupId: this.groupId,
memberId,
});
window.alert(this.$t('managerRemoved')); // eslint-disable-line no-alert
},
close () {
this.$root.$emit('bv::hide::modal', 'members-modal');
},
changeSortOption (e) {
this.sortOption.value = e.value;
this.sort();
},
changeSortDirection (e) {
this.sortOption.direction = e.value;
this.sort();
},
sort () {
this.sortDirty = true;
this.members = orderBy(this.members, [this.sortOption.value], [this.sortOption.direction]);
},
async applySortOptions () {
const settings = {
'party.order': this.sortOption.value,
'party.orderAscending': this.sortOption.direction,
};
await this.$store.dispatch('user:set', settings);
this.sortDirty = false;
},
async loadMoreMembers () {
const lastMember = this.members[this.members.length - 1];
if (!lastMember) return;
const newMembers = await this.$store.state.memberModalOptions.fetchMoreMembers({
challengeId: this.challengeId,
groupId: this.groupId,
lastMemberId: lastMember._id,
includeAllPublicFields: true,
});
this.members = this.members.concat(newMembers);
},
viewMembers () {
this.selectedPage = 'members';
},
viewInvites () {
this.selectedPage = 'invites';
},
async removeInvite (member, index) {
this.invites.splice(index, 1);
await this.$store.dispatch('members:removeMember', {
memberId: member._id,
groupId: this.groupId,
});
if (this.invites.length === 0) this.viewMembers();
},
async promoteToLeader (member) {
const groupData = { ...this.group };
groupData.leader = member._id;
await this.$store.dispatch('guilds:update', { group: groupData });
window.alert(this.$t('leaderChanged')); // eslint-disable-line no-alert
groupData.leader = member;
this.$root.$emit('updatedGroup', groupData);
},
viewProgress (member) {
this.$root.$emit('habitica:challenge:member-progress', {
progressMemberId: member._id,
});
},
shouldShowAddManager (memberId) {
if (!this.isLeader && !this.isAdmin) return false;
if (memberId === this.group.leader || memberId === this.group.leader._id) return false;
return this.groupIsSubscribed && !(this.group.managers && this.group.managers[memberId]);
},
shouldShowRemoveManager (memberId) {
if (!this.isLeader && !this.isAdmin) return false;
return this.group.managers && this.group.managers[memberId];
},
shouldShowLeaderFunctions (memberId) {
return !this.challengeId && (this.isLeader || this.isAdmin) && this.user._id !== memberId;
},
},
};
</script>