mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-14 13:17:24 +01:00
* Load all participants when end challenge modal is opened. * Fetch members in batches until members are loaded * Fix challenge winner search to load all participants Separated loading flags to prevent conflicts between modals * Rename end challenge members flag to be more clear * await load members * Implement challenge member search only when searching w/debounce
736 lines
20 KiB
Vue
736 lines
20 KiB
Vue
<template>
|
|
<div class="row">
|
|
<report-challenge-modal />
|
|
<challenge-modal @updatedChallenge="updatedChallenge" />
|
|
<leave-challenge-modal
|
|
:challenge-id="challenge._id"
|
|
@update-challenge="updateChallenge"
|
|
/>
|
|
<close-challenge-modal
|
|
:challenge-id="challenge._id"
|
|
:prize="challenge.prize"
|
|
:flag-count="challenge.flagCount"
|
|
/>
|
|
<challenge-member-progress-modal :challenge-id="challenge._id" />
|
|
<div class="col-12 col-md-8 standard-page">
|
|
<div class="row">
|
|
<div class="col-12 col-md-6">
|
|
<div
|
|
v-if="canViewFlags"
|
|
class="flagged"
|
|
>
|
|
<div
|
|
v-if="flaggedNotHidden"
|
|
>
|
|
{{ $t("flaggedNotHidden") }}
|
|
</div>
|
|
<div
|
|
v-else-if="flaggedAndHidden"
|
|
>
|
|
{{ $t("flaggedAndHidden") }}
|
|
</div>
|
|
</div>
|
|
<h1 v-markdown="challenge.name"></h1>
|
|
<div>
|
|
<span class="mr-1 ml-0 d-block">
|
|
<strong v-once>{{ $t('createdBy') }}:</strong>
|
|
<span v-if="challenge.leader === null">
|
|
{{ $t('noChallengeOwner') }}
|
|
</span>
|
|
<user-link
|
|
v-else
|
|
class="mx-1"
|
|
:user="challenge.leader"
|
|
/>
|
|
</span>
|
|
<span
|
|
v-if="challenge.group && challenge.group.name !== 'Tavern'"
|
|
class="mr-1 ml-0 d-block"
|
|
>
|
|
<strong v-once>{{ $t(challenge.group.type) }}:</strong>
|
|
<group-link
|
|
class="mx-1"
|
|
:group="challenge.group"
|
|
/>
|
|
</span>
|
|
<!-- @TODO: make challenge.author a variable inside the
|
|
createdBy string (helps with RTL languages)-->
|
|
<!-- @TODO: Implement in V2 strong.margin-left
|
|
(v-once).svg-icon.calendar-icon(v-html="icons.calendarIcon")
|
|
{{$t('endDate')}}
|
|
// "endDate": "End Date: <% endDate %>",-->
|
|
<!-- span {{challenge.endDate}}-->
|
|
</div>
|
|
<div class="tags">
|
|
<span
|
|
v-for="tag in challenge.tags"
|
|
:key="tag"
|
|
class="tag"
|
|
>{{ tag }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-6 text-right">
|
|
<div
|
|
class="box member-count p-2"
|
|
@click="showMemberModal()"
|
|
>
|
|
<div class="box-content">
|
|
<div class="icon-number-row">
|
|
<div
|
|
class="svg-icon member-icon"
|
|
v-html="icons.memberIcon"
|
|
></div>
|
|
<span class="number">{{ challenge.memberCount }}</span>
|
|
</div>
|
|
<div
|
|
v-once
|
|
class="details"
|
|
>
|
|
{{ $t('participantsTitle') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="box prize-count p-2">
|
|
<div class="box-content">
|
|
<div class="icon-number-row">
|
|
<div
|
|
class="svg-icon gem-icon"
|
|
v-html="icons.gemIcon"
|
|
></div>
|
|
<span class="number">{{ challenge.prize || 0 }}</span>
|
|
</div>
|
|
<div
|
|
v-once
|
|
class="details"
|
|
>
|
|
{{ $t('prize') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row challenge-actions">
|
|
<div class="col-12 col-md-6">
|
|
<strong class="view-progress">{{ $t('viewProgressOf') }}</strong>
|
|
<member-search-dropdown
|
|
:text="$t('selectParticipant')"
|
|
:members="members"
|
|
:challenge-id="challengeId"
|
|
@member-selected="openMemberProgressModal"
|
|
@opened="initialMembersLoad()"
|
|
/>
|
|
</div>
|
|
<div class="col-12 col-md-6 text-right">
|
|
<span v-if="isLeader || isAdmin">
|
|
<b-dropdown
|
|
class="create-dropdown select-list"
|
|
:text="$t('addTask')"
|
|
:variant="'success'"
|
|
>
|
|
<b-dropdown-item
|
|
v-for="type in columns"
|
|
:key="type"
|
|
class="selectListItem"
|
|
@click="createTask(type)"
|
|
>{{ $t(type) }}</b-dropdown-item>
|
|
</b-dropdown>
|
|
<task-modal
|
|
ref="taskModal"
|
|
:task="workingTask"
|
|
:purpose="taskFormPurpose"
|
|
:challenge-id="challengeId"
|
|
@cancel="cancelTaskModal()"
|
|
@taskCreated="taskCreated"
|
|
@taskEdited="taskEdited"
|
|
@taskDestroyed="taskDestroyed"
|
|
/>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
|
|
<task-column
|
|
v-for="column in columns"
|
|
v-if="tasksByType[column].length > 0"
|
|
:key="column"
|
|
class="col-12 col-sm-6"
|
|
:type="column"
|
|
:task-list-override="tasksByType[column]"
|
|
:challenge="challenge"
|
|
:draggable-override="isLeader || isAdmin"
|
|
@editTask="editTask"
|
|
@taskDestroyed="taskDestroyed"
|
|
/>
|
|
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-4 sidebar standard-page">
|
|
<div
|
|
v-if="canJoin"
|
|
class="button-container"
|
|
>
|
|
<button
|
|
v-once
|
|
class="btn btn-success"
|
|
@click="joinChallenge()"
|
|
>
|
|
{{ $t('joinChallenge') }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="isLeader || isAdmin"
|
|
class="button-container"
|
|
>
|
|
<button
|
|
v-once
|
|
class="btn btn-primary"
|
|
@click="edit()"
|
|
>
|
|
{{ $t('editChallenge') }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="isLeader || isAdmin"
|
|
class="button-container"
|
|
>
|
|
<div>
|
|
<button
|
|
class="btn"
|
|
:disabled="flaggedAndHidden || chatRevocation"
|
|
:class="flaggedAndHidden || chatRevocation
|
|
? 'disabled btn-disabled' : 'btn-primary'"
|
|
@click="cloneChallenge()"
|
|
>
|
|
{{ $t('clone') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="isLeader || isAdmin"
|
|
class="button-container"
|
|
>
|
|
<button
|
|
v-once
|
|
class="btn btn-primary"
|
|
@click="exportChallengeCsv()"
|
|
>
|
|
{{ $t('exportChallengeCsv') }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="isLeader || isAdmin"
|
|
class="button-container"
|
|
>
|
|
<button
|
|
v-once
|
|
class="btn btn-danger"
|
|
@click="closeChallenge()"
|
|
>
|
|
{{ $t('endChallenge') }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="!isOfficial"
|
|
class="button-container"
|
|
>
|
|
<button
|
|
v-once
|
|
class="btn btn-danger"
|
|
@click="reportChallenge()"
|
|
>
|
|
{{ $t('report') }}
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<sidebar-section :title="$t('challengeSummary')">
|
|
<p v-markdown="challenge.summary"></p>
|
|
</sidebar-section>
|
|
<sidebar-section :title="$t('challengeDescription')">
|
|
<p v-markdown="challenge.description"></p>
|
|
</sidebar-section>
|
|
</div>
|
|
<div
|
|
v-if="isMember"
|
|
class="text-center"
|
|
>
|
|
<button
|
|
v-once
|
|
class="btn btn-danger"
|
|
@click="leaveChallenge()"
|
|
>
|
|
{{ $t('leaveChallenge') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang='scss' scoped>
|
|
@import '@/assets/scss/colors.scss';
|
|
|
|
h1 {
|
|
color: $purple-200;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.margin-left {
|
|
margin-left: 1em;
|
|
}
|
|
|
|
span {
|
|
margin-left: .5em;
|
|
}
|
|
|
|
.button-container {
|
|
margin-bottom: 1em;
|
|
|
|
button {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
.calendar-icon {
|
|
width: 12px;
|
|
display: inline-block;
|
|
margin-right: .2em;
|
|
}
|
|
|
|
.tags {
|
|
margin-top: 1em;
|
|
}
|
|
|
|
.tag {
|
|
border-radius: 30px;
|
|
background-color: $gray-600;
|
|
padding: .5em;
|
|
}
|
|
|
|
.sidebar {
|
|
background-color: $gray-600;
|
|
}
|
|
|
|
.box {
|
|
display: inline-block;
|
|
border-radius: 2px;
|
|
background-color: $white;
|
|
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
|
|
margin-left: 1em;
|
|
width: 120px;
|
|
height: 76px;
|
|
text-align: center;
|
|
font-size: 20px;
|
|
vertical-align: bottom;
|
|
overflow: hidden;
|
|
position: relative;
|
|
|
|
&.member-count:hover {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.box-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
.icon-number-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 0.1em;
|
|
|
|
.number {
|
|
font-size: 20px;
|
|
font-weight: normal;
|
|
margin-left: 0.2em;
|
|
}
|
|
}
|
|
|
|
.svg-icon {
|
|
width: 30px;
|
|
display: inline-block;
|
|
vertical-align: bottom;
|
|
}
|
|
|
|
.details {
|
|
font-size: 12px;
|
|
color: $gray-200;
|
|
width: 100%;
|
|
padding: 0 4px;
|
|
line-height: 1.15;
|
|
word-break: break-word;
|
|
max-height: 2.3em;
|
|
overflow: visible;
|
|
}
|
|
|
|
&.member-count {
|
|
.icon-number-row {
|
|
.svg-icon {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
|
|
.number {
|
|
font-size: 18px;
|
|
}
|
|
}
|
|
|
|
.details {
|
|
font-size: 11px;
|
|
line-height: 1.1;
|
|
max-height: 2.2em;
|
|
}
|
|
}
|
|
|
|
&.prize-count {
|
|
.icon-number-row {
|
|
.svg-icon {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
|
|
.number {
|
|
font-size: 18px;
|
|
}
|
|
}
|
|
|
|
.details {
|
|
font-size: 11px;
|
|
line-height: 1.1;
|
|
max-height: 2.2em;
|
|
}
|
|
}
|
|
}
|
|
|
|
.description-section {
|
|
margin-top: 2em;
|
|
}
|
|
|
|
.challenge-actions {
|
|
margin-top: 1em;
|
|
margin-bottom: 2em;
|
|
|
|
.view-progress {
|
|
margin-right: .5em;
|
|
}
|
|
}
|
|
|
|
.flagged {
|
|
margin-left: 0em;
|
|
color: $red-10;
|
|
|
|
span {
|
|
margin-left: 0em;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
import Vue from 'vue';
|
|
import findIndex from 'lodash/findIndex';
|
|
import cloneDeep from 'lodash/cloneDeep';
|
|
import omit from 'lodash/omit';
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
import taskDefaults from '@/../../common/script/libs/taskDefaults';
|
|
import { userStateMixin } from '../../mixins/userState';
|
|
import externalLinks from '../../mixins/externalLinks';
|
|
import memberSearchDropdown from '@/components/members/memberSearchDropdown';
|
|
import closeChallengeModal from './closeChallengeModal';
|
|
import Column from '../tasks/column';
|
|
import TaskModal from '../tasks/taskModal';
|
|
import markdownDirective from '@/directives/markdown';
|
|
import challengeModal from './challengeModal';
|
|
import challengeMemberProgressModal from './challengeMemberProgressModal';
|
|
import challengeMemberSearchMixin from '@/mixins/challengeMemberSearch';
|
|
import leaveChallengeModal from './leaveChallengeModal';
|
|
import reportChallengeModal from './reportChallengeModal';
|
|
import sidebarSection from '../sidebarSection';
|
|
import userLink from '../userLink';
|
|
import groupLink from '../groupLink';
|
|
|
|
import gemIcon from '@/assets/svg/gem.svg?raw';
|
|
import memberIcon from '@/assets/svg/member-icon.svg?raw';
|
|
import calendarIcon from '@/assets/svg/calendar.svg?raw';
|
|
|
|
const TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'history', 'id', 'streak', 'createdAt', 'challenge'];
|
|
|
|
export default {
|
|
directives: {
|
|
markdown: markdownDirective,
|
|
},
|
|
components: {
|
|
closeChallengeModal,
|
|
leaveChallengeModal,
|
|
reportChallengeModal,
|
|
challengeModal,
|
|
challengeMemberProgressModal,
|
|
memberSearchDropdown,
|
|
sidebarSection,
|
|
TaskColumn: Column,
|
|
TaskModal,
|
|
userLink,
|
|
groupLink,
|
|
},
|
|
mixins: [challengeMemberSearchMixin, externalLinks, userStateMixin],
|
|
async beforeRouteUpdate (to, from, next) {
|
|
this.searchId = to.params.challengeId;
|
|
await this.loadChallenge();
|
|
next();
|
|
},
|
|
props: ['challengeId'],
|
|
data () {
|
|
return {
|
|
searchId: '',
|
|
columns: ['habit', 'daily', 'todo', 'reward'],
|
|
icons: Object.freeze({
|
|
gemIcon,
|
|
memberIcon,
|
|
calendarIcon,
|
|
}),
|
|
challenge: {},
|
|
members: [],
|
|
membersLoaded: false,
|
|
tasksByType: {
|
|
habit: [],
|
|
daily: [],
|
|
todo: [],
|
|
reward: [],
|
|
},
|
|
editingTask: {},
|
|
creatingTask: {},
|
|
workingTask: {},
|
|
taskFormPurpose: 'create',
|
|
searchTerm: '',
|
|
memberResults: [],
|
|
isOfficial: true,
|
|
};
|
|
},
|
|
computed: {
|
|
isMember () {
|
|
return this.user.challenges.indexOf(this.challenge._id) !== -1;
|
|
},
|
|
isLeader () {
|
|
if (!this.challenge.leader) return false;
|
|
return this.user._id === this.challenge.leader._id;
|
|
},
|
|
isAdmin () {
|
|
return this.hasPermission(this.user, 'challengeAdmin');
|
|
},
|
|
canJoin () {
|
|
return !this.isMember;
|
|
},
|
|
// canViewFlags should allow only moderators/admins to see flags
|
|
canViewFlags () {
|
|
const isModerator = this.hasPermission(this.user, 'moderator');
|
|
if (isModerator && this.challenge.flagCount > 0) return true;
|
|
return false;
|
|
},
|
|
// flaggedNotHidden should allow mods/admins & challenge owner to see flags
|
|
flaggedNotHidden () {
|
|
return this.challenge.flagCount === 1;
|
|
},
|
|
// flaggedAndHidden should only allow admin to see challenge & flags
|
|
flaggedAndHidden () {
|
|
return this.challenge.flagCount > 1;
|
|
},
|
|
chatRevocation () {
|
|
return this.user.flags.chatRevoked
|
|
&& this.challenge.group && this.challenge.group.name === 'Tavern';
|
|
},
|
|
},
|
|
watch: {
|
|
'challenge.name': {
|
|
handler (newVal) {
|
|
this.$store.dispatch('common:setTitle', {
|
|
section: this.$t('challenge'),
|
|
subSection: newVal.name,
|
|
});
|
|
},
|
|
},
|
|
},
|
|
async mounted () {
|
|
if (!this.searchId) this.searchId = this.challengeId;
|
|
if (!this.challenge._id) await this.loadChallenge();
|
|
this.isOfficial = this.challenge.official
|
|
|| this.challenge.categories?.some(category => category.name === 'habitica_official');
|
|
this.handleExternalLinks();
|
|
},
|
|
updated () {
|
|
this.handleExternalLinks();
|
|
},
|
|
methods: {
|
|
cleanUpTask (task) {
|
|
const cleansedTask = omit(task, TASK_KEYS_TO_REMOVE);
|
|
|
|
// Copy checklists but reset to uncomplete and assign new id
|
|
if (!cleansedTask.checklist) cleansedTask.checklist = [];
|
|
cleansedTask.checklist.forEach(item => {
|
|
item.completed = false;
|
|
item.id = uuid();
|
|
});
|
|
|
|
if (cleansedTask.type !== 'reward') {
|
|
delete cleansedTask.value;
|
|
}
|
|
|
|
return cleansedTask;
|
|
},
|
|
async loadChallenge () {
|
|
try {
|
|
this.challenge = await this.$store.dispatch('challenges:getChallenge', { challengeId: this.searchId });
|
|
} catch (e) {
|
|
this.$router.push('/challenges/findChallenges');
|
|
return;
|
|
}
|
|
this.$store.dispatch('common:setTitle', {
|
|
subSection: this.challenge.name,
|
|
section: this.$t('challenges'),
|
|
});
|
|
const tasks = await this.$store.dispatch('tasks:getChallengeTasks', { challengeId: this.searchId });
|
|
this.tasksByType = {
|
|
habit: [],
|
|
daily: [],
|
|
todo: [],
|
|
reward: [],
|
|
};
|
|
tasks.forEach(task => {
|
|
this.tasksByType[task.type].push(task);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Method for loading members of a group, with optional parameters for
|
|
* modifying requests.
|
|
*
|
|
* @param {Object} payload Used for modifying requests for members
|
|
*/
|
|
loadMembers (payload = null) {
|
|
// Remove unnecessary data
|
|
if (payload && payload.groupId) {
|
|
delete payload.groupId;
|
|
}
|
|
return this.$store.dispatch('members:getChallengeMembers', payload);
|
|
},
|
|
initialMembersLoad () {
|
|
this.$store.state.memberModalOptions.loading = true;
|
|
if (!this.membersLoaded) {
|
|
this.membersLoaded = true;
|
|
|
|
this.loadMembers({
|
|
challengeId: this.searchId,
|
|
includeAllPublicFields: true,
|
|
}).then(m => {
|
|
this.members.push(...m);
|
|
this.$store.state.memberModalOptions.loading = false;
|
|
});
|
|
} else {
|
|
this.$store.state.memberModalOptions.loading = false;
|
|
}
|
|
},
|
|
editTask (task) {
|
|
this.taskFormPurpose = 'edit';
|
|
this.editingTask = cloneDeep(task);
|
|
this.workingTask = this.editingTask;
|
|
// Necessary otherwise the first time the modal is not rendered
|
|
Vue.nextTick(() => {
|
|
this.$root.$emit('bv::show::modal', 'task-modal');
|
|
});
|
|
},
|
|
createTask (type) {
|
|
this.taskFormPurpose = 'create';
|
|
this.creatingTask = taskDefaults({ type, text: '' }, this.user);
|
|
this.workingTask = this.creatingTask;
|
|
// Necessary otherwise the first time the modal is not rendered
|
|
Vue.nextTick(() => {
|
|
this.$root.$emit('bv::show::modal', 'task-modal');
|
|
});
|
|
},
|
|
cancelTaskModal () {
|
|
this.editingTask = null;
|
|
this.creatingTask = null;
|
|
},
|
|
taskCreated (task) {
|
|
this.tasksByType[task.type].unshift(task);
|
|
},
|
|
taskEdited (task) {
|
|
const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id);
|
|
this.tasksByType[task.type].splice(index, 1, task);
|
|
},
|
|
taskDestroyed (task) {
|
|
const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id);
|
|
this.tasksByType[task.type].splice(index, 1);
|
|
},
|
|
showMemberModal () {
|
|
this.initialMembersLoad();
|
|
|
|
this.$root.$emit('habitica:show-member-modal', {
|
|
challengeId: this.challenge._id,
|
|
groupId: 'challenge', // @TODO: change these terrible settings
|
|
group: this.challenge.group,
|
|
memberCount: this.challenge.memberCount,
|
|
viewingMembers: this.members,
|
|
fetchMoreMembers: this.loadMembers,
|
|
});
|
|
},
|
|
async joinChallenge () {
|
|
this.user.challenges.push(this.searchId);
|
|
this.challenge = await this.$store.dispatch('challenges:joinChallenge', { challengeId: this.searchId });
|
|
this.membersLoaded = false;
|
|
this.members = [];
|
|
|
|
await Promise.all([
|
|
this.$store.dispatch('user:fetch', { forceLoad: true }),
|
|
this.$store.dispatch('tasks:fetchUserTasks', { forceLoad: true }),
|
|
]);
|
|
},
|
|
async leaveChallenge () {
|
|
this.$root.$emit('bv::show::modal', 'leave-challenge-modal');
|
|
},
|
|
async updateChallenge () {
|
|
this.challenge = await this.$store.dispatch('challenges:getChallenge', { challengeId: this.searchId });
|
|
this.membersLoaded = false;
|
|
this.members = [];
|
|
},
|
|
closeChallenge () {
|
|
this.$root.$emit('bv::show::modal', 'close-challenge-modal');
|
|
},
|
|
edit () {
|
|
this.$root.$emit('habitica:update-challenge', {
|
|
challenge: this.challenge,
|
|
});
|
|
},
|
|
// @TODO: view members
|
|
updatedChallenge (eventData) {
|
|
Object.assign(this.challenge, eventData.challenge);
|
|
},
|
|
openMemberProgressModal (member) {
|
|
this.$root.$emit('habitica:challenge:member-progress', {
|
|
progressMemberId: member._id,
|
|
isLeader: this.isLeader,
|
|
isAdmin: this.isAdmin,
|
|
});
|
|
},
|
|
async exportChallengeCsv () {
|
|
window.location = `/api/v4/challenges/${this.searchId}/export/csv`;
|
|
},
|
|
cloneChallenge () {
|
|
this.$root.$emit('habitica:clone-challenge', {
|
|
challenge: this.challenge,
|
|
});
|
|
},
|
|
reportChallenge () {
|
|
this.$root.$emit('habitica::report-challenge', {
|
|
challenge: this.challenge,
|
|
});
|
|
},
|
|
async showCannotCloneModal () {
|
|
this.$root.$emit('bv::show::modal', 'cannot-clone-modal');
|
|
},
|
|
},
|
|
};
|
|
</script>
|