mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-18 07:07:35 +01:00
Merge branch 'release' into sabrecat/timetrav-bgs
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,3 +37,6 @@ yarn.lock
|
||||
.elasticbeanstalk/*
|
||||
!.elasticbeanstalk/*.cfg.yml
|
||||
!.elasticbeanstalk/*.global.yml
|
||||
|
||||
# webstorm fake webpack for path intellisense
|
||||
webpack.webstorm.config
|
||||
|
||||
@@ -3,9 +3,12 @@ FROM node:12
|
||||
# Install global packages
|
||||
RUN npm install -g gulp-cli mocha
|
||||
|
||||
# Copy Habitica code into container and install dependencies
|
||||
# Copy package.json and package-lock.json into image, then install
|
||||
# dependencies.
|
||||
WORKDIR /usr/src/habitica
|
||||
COPY . /usr/src/habitica
|
||||
|
||||
COPY ["package.json", "package-lock.json", "./"]
|
||||
RUN npm install
|
||||
|
||||
# Copy the remaining source files in.
|
||||
COPY . /usr/src/habitica
|
||||
RUN npm run postinstall
|
||||
|
||||
@@ -15,8 +15,9 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- .:/code
|
||||
- /code/node_modules
|
||||
- .:/usr/src/habitica
|
||||
- /usr/src/habitica/node_modules
|
||||
- /usr/src/habitica/website/client/node_modules
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
@@ -32,8 +33,8 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- .:/code
|
||||
- /code/node_modules
|
||||
- .:/usr/src/habitica
|
||||
- /usr/src/habitica/node_modules
|
||||
mongo:
|
||||
image: mongo:3.6
|
||||
networks:
|
||||
|
||||
@@ -50,20 +50,22 @@ async function updateUser (user) {
|
||||
set['achievements.purchasedEquipment'] = true;
|
||||
}
|
||||
|
||||
const hasTask = Object.keys(user.tasksOrder).find(tasksOrderType => {
|
||||
const order = user.tasksOrder[tasksOrderType];
|
||||
if (user.tasksOrder) {
|
||||
const hasTask = Object.keys(user.tasksOrder).find(tasksOrderType => {
|
||||
const order = user.tasksOrder[tasksOrderType];
|
||||
|
||||
if (order && order.length > 0) return true;
|
||||
return false;
|
||||
});
|
||||
if (order && order.length > 0) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasTask) {
|
||||
set['achievements.createdTask'] = true;
|
||||
}
|
||||
if (hasTask) {
|
||||
set['achievements.createdTask'] = true;
|
||||
}
|
||||
|
||||
const hasExperience = user.stats && user.stats.exp && user.stats.exp > 0;
|
||||
if (hasTask && hasExperience) {
|
||||
set['achievements.completedTask'] = true;
|
||||
const hasExperience = user.stats && user.stats.exp && user.stats.exp > 0;
|
||||
if (hasTask && hasExperience) {
|
||||
set['achievements.completedTask'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
|
||||
|
||||
@@ -27,7 +27,7 @@ function uploadFile (buffer, fileName) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
// console.info(`${fileName} uploaded to ${BUCKET_NAME} succesfully.`);
|
||||
// console.info(`${fileName} uploaded to ${BUCKET_NAME} successfully.`);
|
||||
resolve(fileName);
|
||||
}
|
||||
});
|
||||
|
||||
1468
package-lock.json
generated
1468
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"name": "habitica",
|
||||
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
|
||||
"version": "4.129.1",
|
||||
"version": "4.129.4",
|
||||
"main": "./website/server/index.js",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.7.5",
|
||||
"@babel/preset-env": "^7.7.6",
|
||||
"@babel/register": "^7.7.4",
|
||||
"@google-cloud/trace-agent": "^4.2.4",
|
||||
"@babel/core": "^7.8.0",
|
||||
"@babel/preset-env": "^7.8.2",
|
||||
"@babel/register": "^7.8.0",
|
||||
"@google-cloud/trace-agent": "^4.2.5",
|
||||
"@slack/client": "^3.8.1",
|
||||
"accepts": "^1.3.5",
|
||||
"amazon-payments": "^0.2.7",
|
||||
"amazon-payments": "^0.2.8",
|
||||
"amplitude": "^3.5.0",
|
||||
"apidoc": "^0.17.5",
|
||||
"apn": "^2.2.0",
|
||||
"aws-sdk": "^2.590.0",
|
||||
"aws-sdk": "^2.601.0",
|
||||
"bcrypt": "^3.0.7",
|
||||
"body-parser": "^1.18.3",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-session": "^1.3.3",
|
||||
"cookie-session": "^1.4.0",
|
||||
"coupon-code": "^0.4.5",
|
||||
"csv-stringify": "^5.3.4",
|
||||
"csv-stringify": "^5.3.6",
|
||||
"cwait": "^1.1.1",
|
||||
"domain-middleware": "~0.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-habitrpg": "^6.2.0",
|
||||
"eslint-plugin-mocha": "^5.0.0",
|
||||
"express": "^4.16.3",
|
||||
@@ -46,7 +46,7 @@
|
||||
"method-override": "^3.0.0",
|
||||
"moment": "^2.24.0",
|
||||
"moment-recur": "^1.0.7",
|
||||
"mongoose": "^5.8.1",
|
||||
"mongoose": "^5.8.7",
|
||||
"morgan": "^1.7.0",
|
||||
"nconf": "^0.10.0",
|
||||
"node-gcm": "^1.0.2",
|
||||
@@ -62,8 +62,8 @@
|
||||
"remove-markdown": "^0.3.0",
|
||||
"rimraf": "^3.0.0",
|
||||
"short-uuid": "^3.0.0",
|
||||
"stripe": "^7.14.0",
|
||||
"superagent": "^5.1.2",
|
||||
"stripe": "^7.15.0",
|
||||
"superagent": "^5.2.1",
|
||||
"universal-analytics": "^0.4.17",
|
||||
"useragent": "^2.1.9",
|
||||
"uuid": "^3.3.3",
|
||||
@@ -71,7 +71,7 @@
|
||||
"vinyl-buffer": "^1.0.1",
|
||||
"winston": "^2.4.3",
|
||||
"winston-loggly-bulk": "^2.0.2",
|
||||
"xml2js": "^0.4.4"
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
"private": true,
|
||||
"engines": {
|
||||
@@ -103,17 +103,17 @@
|
||||
"apidoc": "gulp apidoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axios": "^0.19.0",
|
||||
"axios": "^0.19.1",
|
||||
"chai": "^4.1.2",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"chalk": "^2.4.1",
|
||||
"expect.js": "^0.3.1",
|
||||
"istanbul": "^1.1.0-alpha.1",
|
||||
"mocha": "^5.1.1",
|
||||
"monk": "^7.1.1",
|
||||
"monk": "^7.1.2",
|
||||
"require-again": "^2.0.0",
|
||||
"sinon": "^7.2.4",
|
||||
"sinon-chai": "^3.0.0",
|
||||
"sinon-chai": "^3.4.0",
|
||||
"sinon-stub-promise": "^4.0.0"
|
||||
},
|
||||
"optionalDependencies": {}
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('analyticsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sets Unkown if headers are not passed in', () => {
|
||||
it('sets Unknown if headers are not passed in', () => {
|
||||
delete data.headers;
|
||||
|
||||
return analyticsService.track(eventType, data)
|
||||
@@ -476,7 +476,7 @@ describe('analyticsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sets Unkown if headers are not passed in', () => {
|
||||
it('sets Unknown if headers are not passed in', () => {
|
||||
delete data.headers;
|
||||
|
||||
return analyticsService.trackPurchase(data)
|
||||
|
||||
@@ -626,7 +626,7 @@ describe('payments/index', () => {
|
||||
.calledWith(recipient, { receiverMsg: msg, senderMsg: msg, save: false });
|
||||
});
|
||||
|
||||
it('sends a message from purchaser to recipient wtih custom message', async () => {
|
||||
it('sends a message from purchaser to recipient with custom message', async () => {
|
||||
data.gift.message = 'giftmessage';
|
||||
|
||||
await api.buyGems(data);
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('webhooks', () => {
|
||||
options: {
|
||||
questStarted: true,
|
||||
questFinised: true,
|
||||
questInvited: true,
|
||||
},
|
||||
}, {
|
||||
id: 'userActivity',
|
||||
@@ -576,7 +577,7 @@ describe('webhooks', () => {
|
||||
};
|
||||
});
|
||||
|
||||
['questStarted', 'questFinised'].forEach(type => {
|
||||
['questStarted', 'questFinised', 'questInvited'].forEach(type => {
|
||||
it(`sends ${type} webhooks`, () => {
|
||||
data.type = type;
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ describe('Webhook Model', () => {
|
||||
options: {
|
||||
questStarted: true,
|
||||
questFinished: true,
|
||||
questInvited: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -197,6 +198,7 @@ describe('Webhook Model', () => {
|
||||
expect(wh.options).to.eql({
|
||||
questStarted: false,
|
||||
questFinished: false,
|
||||
questInvited: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,6 +212,7 @@ describe('Webhook Model', () => {
|
||||
expect(wh.options).to.eql({
|
||||
questStarted: false,
|
||||
questFinished: true,
|
||||
questInvited: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,6 +227,7 @@ describe('Webhook Model', () => {
|
||||
expect(wh.options).to.eql({
|
||||
questStarted: true,
|
||||
questFinished: true,
|
||||
questInvited: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('POST /challenges', () => {
|
||||
expect(groupLeader.balance).to.eql(oldUserBalance);
|
||||
});
|
||||
|
||||
it('sets all properites of the challenge as passed', async () => {
|
||||
it('sets all properties of the challenge as passed', async () => {
|
||||
const name = 'Test Challenge';
|
||||
const shortName = 'TC Label';
|
||||
const description = 'Test Description';
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('GET /groups/:id', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('removes non-existant guild from user\'s guild list', async () => {
|
||||
it('removes non-existent guild from user\'s guild list', async () => {
|
||||
const guildId = generateUUID();
|
||||
|
||||
await user.update({
|
||||
@@ -197,7 +197,7 @@ describe('GET /groups/:id', () => {
|
||||
expect(user.guilds).to.not.include(guildId);
|
||||
});
|
||||
|
||||
it('removes non-existant party from user\'s party object', async () => {
|
||||
it('removes non-existent party from user\'s party object', async () => {
|
||||
const partyId = generateUUID();
|
||||
|
||||
await user.update({
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('POST /groups/:groupId/leave', () => {
|
||||
expect(userWithoutInvitation.invitations.guilds).to.not.be.empty;
|
||||
});
|
||||
|
||||
it('deletes non existant guild from user when user tries to leave', async () => {
|
||||
it('deletes non existent guild from user when user tries to leave', async () => {
|
||||
const nonExistentGuildId = generateUUID();
|
||||
const userWithNonExistentGuild = await generateUser({ guilds: [nonExistentGuildId] });
|
||||
expect(userWithNonExistentGuild.guilds).to.contain(nonExistentGuildId);
|
||||
@@ -258,7 +258,7 @@ describe('POST /groups/:groupId/leave', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes non existant party from user when user tries to leave', async () => {
|
||||
it('deletes non existent party from user when user tries to leave', async () => {
|
||||
const nonExistentPartyId = generateUUID();
|
||||
const userWithNonExistentParty = await generateUser({ 'party._id': nonExistentPartyId });
|
||||
expect(userWithNonExistentParty.party._id).to.eql(nonExistentPartyId);
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('POST /members/send-private-message', () => {
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('notAuthorizedToSendMessageToThisUser'),
|
||||
message: t('blockedToSendToThisUser'),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('POST /members/transfer-gems', () => {
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('notAuthorizedToSendMessageToThisUser'),
|
||||
message: t('blockedToSendToThisUser'),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('payments - amazon - #checkout', () => {
|
||||
amzLib.checkout.restore();
|
||||
});
|
||||
|
||||
it('makes a purcahse with amazon checkout', async () => {
|
||||
it('makes a purchase with amazon checkout', async () => {
|
||||
user = await generateUser({
|
||||
'profile.name': 'sender',
|
||||
'purchased.plan.customerId': 'customer-id',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
createAndPopulateGroup,
|
||||
translate as t,
|
||||
server,
|
||||
sleep,
|
||||
} from '../../../../helpers/api-integration/v3';
|
||||
import { quests as questScrolls } from '../../../../../website/common/script/content/quests';
|
||||
@@ -210,5 +211,39 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => {
|
||||
const returnedGroup = await groupLeader.get(`/groups/${group._id}`);
|
||||
expect(returnedGroup.chat[0]._meta).to.be.undefined;
|
||||
});
|
||||
|
||||
context('sending quest activity webhooks', () => {
|
||||
before(async () => {
|
||||
await server.start();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('sends quest invited webhook', async () => {
|
||||
const uuid = generateUUID();
|
||||
|
||||
await member.post('/user/webhook', {
|
||||
url: `http://localhost:${server.port}/webhooks/${uuid}`,
|
||||
type: 'questActivity',
|
||||
enabled: true,
|
||||
options: {
|
||||
questInvited: true,
|
||||
},
|
||||
});
|
||||
|
||||
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
|
||||
|
||||
await sleep();
|
||||
|
||||
const body = server.getWebhookData(uuid);
|
||||
|
||||
expect(body.type).to.eql('questInvited');
|
||||
expect(body.group.id).to.eql(questingGroup.id);
|
||||
expect(body.group.name).to.eql(questingGroup.name);
|
||||
expect(body.quest.key).to.eql(PET_QUEST);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('DELETE /tasks/:id', () => {
|
||||
});
|
||||
|
||||
context('task cannot be deleted', () => {
|
||||
it('cannot delete a non-existant task', async () => {
|
||||
it('cannot delete a non-existent task', async () => {
|
||||
await expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({
|
||||
code: 404,
|
||||
error: 'NotFound',
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('GET /tasks/:id', () => {
|
||||
});
|
||||
|
||||
context('task cannot be accessed', () => {
|
||||
it('cannot get a non-existant task', async () => {
|
||||
it('cannot get a non-existent task', async () => {
|
||||
const dummyId = generateUUID();
|
||||
|
||||
await expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('DELETE /tasks/:id', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot delete a non-existant task', async () => {
|
||||
it('cannot delete a non-existent task', async () => {
|
||||
await expect(user.del(`/tasks/${generateUUID()}`)).to.eventually.be.rejected.and.eql({
|
||||
code: 404,
|
||||
error: 'NotFound',
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('PUT /user/auth/update-email', () => {
|
||||
const newEmail = 'SOmE-nEw-emAIl_2@example.net';
|
||||
const oldPassword = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js
|
||||
|
||||
context('Local Authenticaion User', async () => {
|
||||
context('Local Authentication User', async () => {
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -226,6 +226,69 @@ describe('POST /user/webhook', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults questActivity options', async () => {
|
||||
body.type = 'questActivity';
|
||||
|
||||
const webhook = await user.post('/user/webhook', body);
|
||||
|
||||
expect(webhook.options).to.eql({
|
||||
questStarted: false,
|
||||
questFinished: false,
|
||||
questInvited: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('can set questActivity options', async () => {
|
||||
body.type = 'questActivity';
|
||||
body.options = {
|
||||
questStarted: true,
|
||||
questFinished: true,
|
||||
questInvited: true,
|
||||
};
|
||||
|
||||
const webhook = await user.post('/user/webhook', body);
|
||||
|
||||
expect(webhook.options).to.eql({
|
||||
questStarted: true,
|
||||
questFinished: true,
|
||||
questInvited: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('discards extra properties in questActivity options', async () => {
|
||||
body.type = 'questActivity';
|
||||
body.options = {
|
||||
questStarted: false,
|
||||
questFinished: true,
|
||||
questInvited: true,
|
||||
foo: 'bar',
|
||||
};
|
||||
|
||||
const webhook = await user.post('/user/webhook', body);
|
||||
|
||||
expect(webhook.options.foo).to.not.exist;
|
||||
expect(webhook.options).to.eql({
|
||||
questStarted: false,
|
||||
questFinished: true,
|
||||
questInvited: true,
|
||||
});
|
||||
});
|
||||
|
||||
['questStarted', 'questFinished', 'questInvited'].forEach(option => {
|
||||
it(`requires questActivity option ${option} to be a boolean`, async () => {
|
||||
body.type = 'questActivity';
|
||||
body.options = {
|
||||
[option]: 'not a boolean',
|
||||
};
|
||||
|
||||
await expect(user.post('/user/webhook', body)).to.eventually.be.rejected.and.eql({
|
||||
code: 400,
|
||||
error: 'BadRequest',
|
||||
message: t('webhookBooleanOption', { option }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('discards extra properties in globalActivity options', async () => {
|
||||
body.type = 'globalActivity';
|
||||
body.options = {
|
||||
|
||||
@@ -15,8 +15,8 @@ describe('getDebuffPotionItems', () => {
|
||||
|
||||
for (const key of Object.keys(TRANSFORMATION_DEBUFFS_LIST)) {
|
||||
const debuff = TRANSFORMATION_DEBUFFS_LIST[key];
|
||||
// Here we itterate whole object to dynamicaly create test suites as
|
||||
// it described in dock of mocha
|
||||
// Here we iterate the whole object to dynamically create test suites as
|
||||
// described in mocha's docs
|
||||
// https://mochajs.org/#dynamically-generating-tests
|
||||
// That's why we have eslint-disable here
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('shared.ops.unlock', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// disabled untill fully implemente
|
||||
// disabled until fully implemente
|
||||
xit('returns an error when user already owns items in a full set', done => {
|
||||
try {
|
||||
unlock(user, { query: { path: unlockPath } });
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { configure } from '@storybook/vue';
|
||||
import '../../src/assets/scss/index.scss';
|
||||
import '../../src/assets/css/sprites.css';
|
||||
|
||||
import '../../src/assets/css/sprites/spritesmith-main-0.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-1.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-2.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-3.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-4.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-5.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-6.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-7.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-8.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-9.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-10.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-11.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-12.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-13.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-14.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-15.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-16.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-17.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-18.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-19.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-20.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-21.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-22.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-23.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-24.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-25.css';
|
||||
import '../../src/assets/css/sprites/spritesmith-main-26.css';
|
||||
import Vue from 'vue';
|
||||
import StoreModule from '@/libs/store';
|
||||
|
||||
const req = require.context('../../src', true, /.stories.js$/);
|
||||
|
||||
@@ -8,4 +39,6 @@ function loadStories () {
|
||||
req.keys().forEach(filename => req(filename));
|
||||
}
|
||||
|
||||
Vue.use(StoreModule);
|
||||
|
||||
configure(loadStories, module);
|
||||
|
||||
75
website/client/config/storybook/mock.data.js
Normal file
75
website/client/config/storybook/mock.data.js
Normal file
@@ -0,0 +1,75 @@
|
||||
export const userStyles = {
|
||||
contributor: {
|
||||
admin: true,
|
||||
level: 9,
|
||||
text: '',
|
||||
},
|
||||
items: {
|
||||
gear: {
|
||||
equipped: {
|
||||
armor: 'armor_special_2',
|
||||
head: 'head_special_2',
|
||||
shield: 'shield_special_goldenknight',
|
||||
headAccessory: 'headAccessory_base_0',
|
||||
eyewear: 'eyewear_base_0',
|
||||
weapon: 'weapon_special_1',
|
||||
back: 'back_base_0',
|
||||
},
|
||||
costume: {
|
||||
armor: 'armor_special_fallRogue',
|
||||
head: 'head_special_fallRogue',
|
||||
shield: 'shield_armoire_shieldOfDiamonds',
|
||||
body: 'body_mystery_201706',
|
||||
eyewear: 'eyewear_special_blackHalfMoon',
|
||||
back: 'back_base_0',
|
||||
headAccessory: 'headAccessory_special_wolfEars',
|
||||
weapon: 'weapon_armoire_lamplighter',
|
||||
},
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
hair: {
|
||||
color: 'black', base: 0, bangs: 3, beard: 0, mustache: 0, flower: 0,
|
||||
},
|
||||
tasks: { groupByChallenge: false, confirmScoreNotes: false },
|
||||
size: 'broad',
|
||||
skin: 'wolf',
|
||||
shirt: 'zombie',
|
||||
chair: 'none',
|
||||
sleep: true,
|
||||
disableClasses: false,
|
||||
background: 'midnight_castle',
|
||||
costume: true,
|
||||
},
|
||||
stats: {
|
||||
buffs: {
|
||||
str: 0,
|
||||
int: 0,
|
||||
per: 0,
|
||||
con: 0,
|
||||
stealth: 0,
|
||||
streaks: false,
|
||||
snowball: false,
|
||||
spookySparkles: false,
|
||||
shinySeed: false,
|
||||
seafoam: false,
|
||||
},
|
||||
training: {
|
||||
int: 0, per: 0, str: 0, con: 0,
|
||||
},
|
||||
hp: 50,
|
||||
mp: 158,
|
||||
exp: 227,
|
||||
gp: 464.31937261345155,
|
||||
lvl: 17,
|
||||
class: 'rogue',
|
||||
points: 17,
|
||||
str: 0,
|
||||
con: 0,
|
||||
int: 0,
|
||||
per: 0,
|
||||
toNextLevel: 380,
|
||||
maxHealth: 50,
|
||||
maxMP: 158,
|
||||
},
|
||||
};
|
||||
5218
website/client/package-lock.json
generated
5218
website/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,39 +13,39 @@
|
||||
"test:unit": "vue-cli-service test:unit --require ./tests/unit/helpers.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.1.1",
|
||||
"@vue/cli-plugin-eslint": "^4.1.1",
|
||||
"@vue/cli-plugin-router": "^4.1.1",
|
||||
"@vue/cli-plugin-unit-mocha": "^4.1.1",
|
||||
"@vue/cli-service": "^4.1.1",
|
||||
"@storybook/addon-actions": "^5.0.0",
|
||||
"@storybook/addon-knobs": "^5.0.0",
|
||||
"@storybook/addon-links": "^5.0.0",
|
||||
"@storybook/addon-notes": "^5.0.0",
|
||||
"@storybook/vue": "^5.2.5",
|
||||
"@vue/cli-plugin-babel": "^4.1.2",
|
||||
"@vue/cli-plugin-eslint": "^4.1.2",
|
||||
"@vue/cli-plugin-router": "^4.1.2",
|
||||
"@vue/cli-plugin-unit-mocha": "^4.1.2",
|
||||
"@vue/cli-service": "^4.1.2",
|
||||
"@storybook/addon-actions": "^5.3.1",
|
||||
"@storybook/addon-knobs": "^5.3.1",
|
||||
"@storybook/addon-links": "^5.3.1",
|
||||
"@storybook/addon-notes": "^5.3.1",
|
||||
"@storybook/vue": "^5.3.1",
|
||||
"@vue/test-utils": "1.0.0-beta.29",
|
||||
"amplitude-js": "^5.8.0",
|
||||
"axios": "^0.19.0",
|
||||
"axios": "^0.19.1",
|
||||
"axios-progress-bar": "^1.2.0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"bootstrap": "^4.4.1",
|
||||
"bootstrap-vue": "^2.1.0",
|
||||
"bootstrap-vue": "^2.2.0",
|
||||
"chai": "^4.1.2",
|
||||
"core-js": "^3.5.0",
|
||||
"eslint": "^6.7.2",
|
||||
"core-js": "^3.6.3",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-habitrpg": "^6.2.0",
|
||||
"eslint-plugin-mocha": "^5.3.0",
|
||||
"eslint-plugin-vue": "^6.0.1",
|
||||
"eslint-plugin-vue": "^6.1.2",
|
||||
"habitica-markdown": "^1.3.2",
|
||||
"hellojs": "^1.18.1",
|
||||
"inspectpack": "^4.2.2",
|
||||
"hellojs": "^1.18.4",
|
||||
"inspectpack": "^4.3.0",
|
||||
"intro.js": "^2.9.3",
|
||||
"jquery": "^3.4.1",
|
||||
"lodash": "^4.17.15",
|
||||
"moment": "^2.24.0",
|
||||
"nconf": "^0.10.0",
|
||||
"sass": "^1.23.7",
|
||||
"sass-loader": "^8.0.0",
|
||||
"sass": "^1.24.4",
|
||||
"sass-loader": "^8.0.1",
|
||||
"smartbanner.js": "^1.15.0",
|
||||
"svg-inline-loader": "^0.8.0",
|
||||
"svg-url-loader": "^3.0.3",
|
||||
@@ -58,8 +58,9 @@
|
||||
"vue-mugen-scroll": "^0.2.6",
|
||||
"vue-router": "^3.0.6",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"vue2-perfect-scrollbar": "^1.3.0",
|
||||
"vuedraggable": "^2.23.1",
|
||||
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#5d237615463a84a23dd6f3f77c6ab577d68593ec",
|
||||
"webpack": "^4.41.2"
|
||||
"webpack": "^4.41.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
</div>
|
||||
<div
|
||||
id="app"
|
||||
:class="{'casting-spell': castingSpell}"
|
||||
:class="{
|
||||
'casting-spell': castingSpell,
|
||||
'resting': showRestingBanner
|
||||
}"
|
||||
>
|
||||
<banned-account-modal />
|
||||
<amazon-payments-modal v-if="!isStaticPage" />
|
||||
@@ -66,7 +69,10 @@
|
||||
</div>
|
||||
<notifications-display />
|
||||
<app-menu />
|
||||
<div class="container-fluid">
|
||||
<div
|
||||
class="container-fluid"
|
||||
:class="{'no-margin': noMargin}"
|
||||
>
|
||||
<app-header />
|
||||
<buyModal
|
||||
:item="selectedItemToBuy || {}"
|
||||
@@ -83,7 +89,7 @@
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
<app-footer />
|
||||
<app-footer v-if="!hideFooter" />
|
||||
<audio
|
||||
id="sound"
|
||||
ref="sound"
|
||||
@@ -97,13 +103,20 @@
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/variables.scss';
|
||||
|
||||
#app {
|
||||
height: calc(100% - 56px); /* 56px is the menu */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
|
||||
&.resting {
|
||||
--banner-resting-height: #{$restingToolbarHeight};
|
||||
}
|
||||
|
||||
&.giftingBanner {
|
||||
--banner-gifting-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
#loading-screen-inapp {
|
||||
@@ -148,6 +161,13 @@
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.no-margin {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.notification {
|
||||
border-radius: 1000px;
|
||||
background-color: $green-10;
|
||||
@@ -160,7 +180,7 @@
|
||||
|
||||
.resting-banner {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
height: $restingToolbarHeight;
|
||||
background-color: $blue-10;
|
||||
top: 0;
|
||||
z-index: 1300;
|
||||
@@ -302,7 +322,13 @@ export default {
|
||||
return this.$t(`tip${tipNumber}`);
|
||||
},
|
||||
showRestingBanner () {
|
||||
return !this.bannerHidden && this.user.preferences.sleep;
|
||||
return !this.bannerHidden && this.user && this.user.preferences.sleep;
|
||||
},
|
||||
noMargin () {
|
||||
return ['privateMessages'].includes(this.$route.name);
|
||||
},
|
||||
hideFooter () {
|
||||
return ['privateMessages'].includes(this.$route.name);
|
||||
},
|
||||
},
|
||||
created () {
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
.promo_armoire_backgrounds_202001 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px -175px;
|
||||
background-position: 0px -323px;
|
||||
width: 423px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_g1g1_2019 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px -471px;
|
||||
background-position: -403px -471px;
|
||||
width: 357px;
|
||||
height: 144px;
|
||||
}
|
||||
.promo_mystery_202001 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -241px -616px;
|
||||
background-position: -241px -619px;
|
||||
width: 279px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_seasonal_shop {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -521px -619px;
|
||||
width: 162px;
|
||||
height: 138px;
|
||||
}
|
||||
.promo_snowballs {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px 0px;
|
||||
@@ -24,25 +30,25 @@
|
||||
}
|
||||
.promo_take_this {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -358px -471px;
|
||||
background-position: -761px -471px;
|
||||
width: 96px;
|
||||
height: 69px;
|
||||
}
|
||||
.promo_winter_potions_2020 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -424px -175px;
|
||||
background-position: 0px -175px;
|
||||
width: 423px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_winter_quests_bundle {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px -323px;
|
||||
background-position: -424px -175px;
|
||||
width: 423px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_winter_wonderland_2019 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -424px -323px;
|
||||
background-position: 0px -471px;
|
||||
width: 402px;
|
||||
height: 147px;
|
||||
}
|
||||
@@ -52,9 +58,27 @@
|
||||
width: 468px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_wintery_hair {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -684px -619px;
|
||||
width: 152px;
|
||||
height: 75px;
|
||||
}
|
||||
.promo_wintery_skins {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -424px -323px;
|
||||
width: 420px;
|
||||
height: 147px;
|
||||
}
|
||||
.customize-option.promo_wintery_skins {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -449px -338px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
.scene_list {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px -616px;
|
||||
background-position: 0px -619px;
|
||||
width: 240px;
|
||||
height: 195px;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 95 KiB |
@@ -7,3 +7,6 @@ $npc_quests_flavor: 'nye';
|
||||
$npc_seasonal_flavor: 'nye';
|
||||
$npc_timetravelers_flavor: 'winter';
|
||||
$npc_tavern_flavor: 'nye';
|
||||
|
||||
$restingToolbarHeight: 40px;
|
||||
$menuToolbarHeight: 56px;
|
||||
|
||||
3
website/client/src/assets/svg/for-css/search_gray.svg
Normal file
3
website/client/src/assets/svg/for-css/search_gray.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="#A5A1AC" fill-rule="evenodd" d="M15.7 14.3l-4.8-4.8c.7-1 1.1-2.2 1.1-3.5 0-3.3-2.7-6-6-6S0 2.7 0 6s2.7 6 6 6c1.3 0 2.5-.4 3.5-1.1l4.8 4.8c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4zM6 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
7
website/client/src/assets/svg/mail.svg
Normal file
7
website/client/src/assets/svg/mail.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="24" viewBox="0 0 32 24">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path fill="#9A62FF" d="M28 0H4C1.79 0 0 1.79 0 4v16c0 2.21 1.79 4 4 4h24c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z"/>
|
||||
<path fill="#BDA8FF" d="M28 20H4V4l12 10L28 4z"/>
|
||||
<path fill="#D5C8FF" d="M28 20H4V7l12 10L28 7z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 380 B |
@@ -32,7 +32,7 @@
|
||||
:avatar-only="true"
|
||||
:with-background="true"
|
||||
/>
|
||||
<!-- @TOOD: Sleep +generatedAvatar({sleep:true})-->
|
||||
<!-- @TODO: Sleep +generatedAvatar({sleep:true})-->
|
||||
<span class="knockout"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,8 +71,8 @@ export default {
|
||||
this.$root.$emit('bv::hide::modal', 'streak');
|
||||
},
|
||||
suppressModals () {
|
||||
const surpress = !!this.user.preferences.suppressModals.streak;
|
||||
this.$store.dispatch('user:set', { 'preferences.suppressModals.streak': surpress });
|
||||
const suppress = !!this.user.preferences.suppressModals.streak;
|
||||
this.$store.dispatch('user:set', { 'preferences.suppressModals.streak': suppress });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<template>
|
||||
<div class="form-wrapper">
|
||||
<div class="warning-banner d-flex" v-if="forgotPassword && preOutage">
|
||||
<div class="warning-box ml-auto my-auto mr-2 d-flex">
|
||||
<div
|
||||
class="svg-icon exclamation m-auto"
|
||||
v-html="icons.exclamation"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-auto my-auto">
|
||||
Habitica emails will be temporarily unavailable on <strong>January 11, 2020</strong> from
|
||||
<strong>1:00 - 7:00 AM EST</strong>.
|
||||
</div>
|
||||
</div>
|
||||
<div id="top-background">
|
||||
<div class="seamless_stars_varied_opacity_repeat"></div>
|
||||
</div>
|
||||
@@ -546,15 +559,36 @@
|
||||
font-size: 90%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
color: $white;
|
||||
background-color: $maroon-100;
|
||||
height: 2.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
font-weight: bold;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.exclamation {
|
||||
width: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import hello from 'hellojs';
|
||||
import moment from 'moment';
|
||||
import debounce from 'lodash/debounce';
|
||||
import isEmail from 'validator/lib/isEmail';
|
||||
|
||||
import { MINIMUM_PASSWORD_LENGTH } from '@/../../common/script/constants';
|
||||
import exclamation from '@/assets/svg/exclamation.svg';
|
||||
import gryphon from '@/assets/svg/gryphon.svg';
|
||||
import habiticaIcon from '@/assets/svg/habitica-logo.svg';
|
||||
import facebookSquareIcon from '@/assets/svg/facebook-square.svg';
|
||||
@@ -576,6 +610,7 @@ export default {
|
||||
};
|
||||
|
||||
data.icons = Object.freeze({
|
||||
exclamation,
|
||||
gryphon,
|
||||
habiticaIcon,
|
||||
facebookIcon: facebookSquareIcon,
|
||||
@@ -635,6 +670,9 @@ export default {
|
||||
|| this.passwordInvalid
|
||||
|| this.passwordConfirmInvalid;
|
||||
},
|
||||
preOutage () {
|
||||
return moment.utc().isBefore('2020-01-12');
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route: {
|
||||
@@ -700,7 +738,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// @TODO: implement langauge and invite accepting
|
||||
// @TODO: implement language and invite accepting
|
||||
// var url = ApiUrl.get() + "/api/v4/user/auth/local/register";
|
||||
// if (location.search && location.search.indexOf('Invite=') !== -1)
|
||||
// { // matches groupInvite and partyInvite
|
||||
|
||||
@@ -294,8 +294,9 @@
|
||||
justify-content: space-evenly;
|
||||
background-color: $gray-700;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
padding: 8px;
|
||||
border-radius: .25em;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> div {
|
||||
.value {
|
||||
@@ -326,6 +327,8 @@
|
||||
}
|
||||
|
||||
> div.muted {
|
||||
margin: 8px;
|
||||
|
||||
.value {
|
||||
opacity: 0.5;
|
||||
font-size: 20px;
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCategorySelect && creating"
|
||||
v-if="showCategorySelect"
|
||||
class="category-box"
|
||||
>
|
||||
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
|
||||
|
||||
@@ -68,6 +68,17 @@
|
||||
<style lang='scss' scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.header-row {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.col-10.standard-page {
|
||||
// full width on smaller devices
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.header-row {
|
||||
h1 {
|
||||
color: $purple-200;
|
||||
|
||||
@@ -88,6 +88,17 @@
|
||||
<style lang='scss' scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.header-row {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.col-10.standard-page {
|
||||
// full width on smaller devices
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.header-row {
|
||||
h1 {
|
||||
color: $purple-200;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="mentioned-icon"
|
||||
></div>
|
||||
<div
|
||||
v-if="!inbox && user.contributor.admin && msg.flagCount"
|
||||
v-if="user.contributor.admin && msg.flagCount"
|
||||
class="message-hidden"
|
||||
>
|
||||
{{ flagCountDescription }}
|
||||
@@ -27,8 +27,7 @@
|
||||
class="mr-1"
|
||||
>•</span>
|
||||
<span
|
||||
v-b-tooltip
|
||||
:title="msg.timestamp | date"
|
||||
v-b-tooltip.hover="messageDate"
|
||||
>{{ msg.timestamp | timeAgo }} </span>
|
||||
<span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span>
|
||||
</p>
|
||||
@@ -37,21 +36,12 @@
|
||||
class="text"
|
||||
v-html="atHighlight(parseMarkdown(msg.text))"
|
||||
></div>
|
||||
<div
|
||||
v-if="isMessageReported && (inbox === true)"
|
||||
class="reported"
|
||||
>
|
||||
<span v-once>{{ $t('reportedMessage') }}</span>
|
||||
<br>
|
||||
<span v-once>{{ $t('canDeleteNow') }}</span>
|
||||
</div>
|
||||
<hr>
|
||||
<div
|
||||
v-if="msg.id"
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
v-if="!inbox"
|
||||
class="action d-flex align-items-center"
|
||||
@click="copyAsTodo(msg)"
|
||||
>
|
||||
@@ -62,7 +52,7 @@
|
||||
<div>{{ $t('copyAsTodo') }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="(inbox || (user.flags.communityGuidelinesAccepted && msg.uuid !== 'system'))
|
||||
v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
|
||||
&& (!isMessageReported || user.contributor.admin)"
|
||||
class="action d-flex align-items-center"
|
||||
@click="report(msg)"
|
||||
@@ -77,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="msg.uuid === user._id || inbox || user.contributor.admin"
|
||||
v-if="msg.uuid === user._id || user.contributor.admin"
|
||||
class="action d-flex align-items-center"
|
||||
@click="remove()"
|
||||
>
|
||||
@@ -91,7 +81,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!inbox"
|
||||
v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}"
|
||||
class="ml-auto d-flex"
|
||||
>
|
||||
@@ -121,7 +110,7 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!msg.likes[user._id] && !inbox">{{ $t('like') }}</span>
|
||||
<span v-if="!msg.likes[user._id]">{{ $t('like') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,15 +194,9 @@
|
||||
color: $purple-400;
|
||||
}
|
||||
}
|
||||
|
||||
.reported {
|
||||
margin-top: 18px;
|
||||
color: $red-50;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import escapeRegExp from 'lodash/escapeRegExp';
|
||||
@@ -244,10 +227,6 @@ export default {
|
||||
},
|
||||
props: {
|
||||
msg: {},
|
||||
inbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
groupId: {},
|
||||
},
|
||||
data () {
|
||||
@@ -311,6 +290,10 @@ export default {
|
||||
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
|
||||
return 'Message hidden (shadow-muted)';
|
||||
},
|
||||
messageDate () {
|
||||
const date = moment(this.msg.timestamp).toDate();
|
||||
return date.toString();
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
const links = this.$refs.markdownContainer.getElementsByTagName('a');
|
||||
@@ -372,11 +355,6 @@ export default {
|
||||
const message = this.msg;
|
||||
this.$emit('message-removed', message);
|
||||
|
||||
if (this.inbox) {
|
||||
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.$store.dispatch('chat:deleteChat', {
|
||||
groupId: this.groupId,
|
||||
chatId: message.id,
|
||||
|
||||
@@ -35,13 +35,11 @@
|
||||
v-for="msg in messages"
|
||||
v-if="chat && canViewFlag(msg)"
|
||||
:key="msg.id"
|
||||
:class="{row: inbox}"
|
||||
>
|
||||
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
|
||||
<div
|
||||
v-if="user._id !== msg.uuid"
|
||||
class="d-flex"
|
||||
:class="{'flex-grow-1': inbox}"
|
||||
>
|
||||
<avatar
|
||||
v-if="msg.userStyles
|
||||
@@ -51,16 +49,13 @@
|
||||
:avatar-only="true"
|
||||
:override-top-padding="'14px'"
|
||||
:hide-class-badge="true"
|
||||
:class="{'inbox-avatar-left': inbox}"
|
||||
@click.native="showMemberModal(msg.uuid)"
|
||||
/>
|
||||
<div
|
||||
class="card"
|
||||
:class="{'col-10': inbox}"
|
||||
>
|
||||
<chat-card
|
||||
:msg="msg"
|
||||
:inbox="inbox"
|
||||
:group-id="groupId"
|
||||
@message-liked="messageLiked"
|
||||
@message-removed="messageRemoved"
|
||||
@@ -72,15 +67,12 @@
|
||||
<div
|
||||
v-if="user._id === msg.uuid"
|
||||
class="d-flex"
|
||||
:class="{'flex-grow-1': inbox}"
|
||||
>
|
||||
<div
|
||||
class="card"
|
||||
:class="{'col-10': inbox}"
|
||||
>
|
||||
<chat-card
|
||||
:msg="msg"
|
||||
:inbox="inbox"
|
||||
:group-id="groupId"
|
||||
@message-liked="messageLiked"
|
||||
@message-removed="messageRemoved"
|
||||
@@ -95,7 +87,6 @@
|
||||
:avatar-only="true"
|
||||
:hide-class-badge="true"
|
||||
:override-top-padding="'14px'"
|
||||
:class="{'inbox-avatar-right': inbox}"
|
||||
@click.native="showMemberModal(msg.uuid)"
|
||||
/>
|
||||
</div>
|
||||
@@ -144,16 +135,6 @@
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.inbox-avatar-left {
|
||||
margin-left: -1rem;
|
||||
margin-right: 2.5rem;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.inbox-avatar-right {
|
||||
margin-left: -3.5rem;
|
||||
}
|
||||
|
||||
.hr {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
@@ -209,10 +190,6 @@ export default {
|
||||
},
|
||||
props: {
|
||||
chat: {},
|
||||
inbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
groupType: {},
|
||||
groupId: {},
|
||||
groupName: {},
|
||||
@@ -260,12 +237,6 @@ export default {
|
||||
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
|
||||
// disable scroll
|
||||
container.style.overflowY = 'hidden';
|
||||
|
||||
const canLoadMore = this.inbox && !this.isLoading && this.canLoadMore;
|
||||
if (canLoadMore) {
|
||||
await this.$emit('triggerLoad');
|
||||
this.handleScrollBack = true;
|
||||
}
|
||||
},
|
||||
canViewFlag (message) {
|
||||
if (message.uuid === this.user._id) return true;
|
||||
@@ -282,7 +253,7 @@ export default {
|
||||
const promises = [];
|
||||
const noProfilesLoaded = Object.keys(this.cachedProfileData).length === 0;
|
||||
|
||||
// @TODO: write an explination
|
||||
// @TODO: write an explanation
|
||||
// @TODO: Remove this after enough messages are cached
|
||||
if (
|
||||
!noProfilesLoaded
|
||||
@@ -380,11 +351,6 @@ export default {
|
||||
this.chat.splice(chatIndex, 1, message);
|
||||
},
|
||||
messageRemoved (message) {
|
||||
if (this.inbox) {
|
||||
this.$emit('message-removed', message);
|
||||
return;
|
||||
}
|
||||
|
||||
const chatIndex = findIndex(this.chat, chatMessage => chatMessage.id === message.id);
|
||||
this.chat.splice(chatIndex, 1);
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div class="footer text-center">
|
||||
<button
|
||||
v-if="user.contributor.admin && abuseObject.flagCount > 0"
|
||||
v-if="user.contributor.admin"
|
||||
class="pull-left btn btn-danger"
|
||||
@click="clearFlagCount()"
|
||||
>
|
||||
|
||||
@@ -382,7 +382,7 @@
|
||||
<div class="task-option">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input
|
||||
id="excercise"
|
||||
id="exercise"
|
||||
v-model="taskCategories"
|
||||
class="custom-control-input"
|
||||
type="checkbox"
|
||||
@@ -391,7 +391,7 @@
|
||||
<label
|
||||
v-once
|
||||
class="custom-control-label"
|
||||
for="excercise"
|
||||
for="exercise"
|
||||
>{{ $t('exercise') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
47
website/client/src/components/faceAvatar.stories.js
Normal file
47
website/client/src/components/faceAvatar.stories.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { storiesOf } from '@storybook/vue';
|
||||
|
||||
import FaceAvatar from './faceAvatar.vue';
|
||||
import Avatar from './avatar.vue';
|
||||
import { userStyles } from '../../config/storybook/mock.data';
|
||||
import content from '../../../common/script/content/index';
|
||||
import getters from '@/store/getters';
|
||||
|
||||
|
||||
storiesOf('Face Avatar', module)
|
||||
.add('simple', () => ({
|
||||
components: { FaceAvatar },
|
||||
template: `
|
||||
<div style="position: absolute; margin: 20px">
|
||||
<face-avatar :member="user"></face-avatar>
|
||||
</div>
|
||||
`,
|
||||
data () {
|
||||
return {
|
||||
user: userStyles,
|
||||
};
|
||||
},
|
||||
}))
|
||||
.add('compare', () => ({
|
||||
components: { FaceAvatar, Avatar },
|
||||
template: `
|
||||
<div style="position: absolute; margin: 20px">
|
||||
<face-avatar :member="user"></face-avatar>
|
||||
<avatar :member="user"></avatar>
|
||||
</div>
|
||||
`,
|
||||
data () {
|
||||
return {
|
||||
user: userStyles,
|
||||
};
|
||||
},
|
||||
state: {
|
||||
content,
|
||||
},
|
||||
store: {
|
||||
getters,
|
||||
state: {
|
||||
content,
|
||||
},
|
||||
},
|
||||
}));
|
||||
154
website/client/src/components/faceAvatar.vue
Normal file
154
website/client/src/components/faceAvatar.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div
|
||||
class="face-avatar"
|
||||
:style="{width, height}"
|
||||
>
|
||||
<div class="character-sprites">
|
||||
<!-- Buffs that cause visual changes to avatar: Snowman, Ghost, Flower, etc-->
|
||||
<template v-for="(klass, item) in visualBuffs">
|
||||
<span
|
||||
v-if="member.stats.buffs[item] && showVisualBuffs"
|
||||
:key="klass"
|
||||
:class="klass"
|
||||
></span>
|
||||
</template>
|
||||
<!-- Show flower ALL THE TIME!!!-->
|
||||
<!-- See https://github.com/HabitRPG/habitica/issues/7133-->
|
||||
<span :class="'hair_flower_' + member.preferences.hair.flower"></span>
|
||||
<!-- Show avatar only if not currently affected by visual buff-->
|
||||
<template v-if="showAvatar()">
|
||||
<span :class="[skinClass]"></span><span :class="['head_0']"></span>
|
||||
<template v-for="type in ['bangs', 'base', 'mustache', 'beard']">
|
||||
<span
|
||||
:key="type"
|
||||
:class="[getHairClass(type)]"
|
||||
></span>
|
||||
</template>
|
||||
<span :class="[getGearClass('body')]"></span>
|
||||
<span :class="[getGearClass('eyewear')]"></span>
|
||||
<span :class="[getGearClass('head')]"></span>
|
||||
<span :class="[getGearClass('headAccessory')]"></span>
|
||||
<span :class="['hair_flower_' + member.preferences.hair.flower]"></span>
|
||||
</template>
|
||||
<!-- Resting--><span
|
||||
v-if="member.preferences.sleep"
|
||||
class="zzz"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.face-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: solid 2px currentColor;
|
||||
border-radius: 18px;
|
||||
image-rendering: pixelated;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.character-sprites {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
margin: -25px -41px;
|
||||
}
|
||||
|
||||
.character-sprites span {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
},
|
||||
props: {
|
||||
member: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
avatarOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideClassBadge: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
withBackground: {
|
||||
type: Boolean,
|
||||
},
|
||||
overrideAvatarGear: {
|
||||
type: Object,
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 140,
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 147,
|
||||
},
|
||||
showVisualBuffs: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
flatGear: 'content.gear.flat',
|
||||
}),
|
||||
hasClass () {
|
||||
return this.$store.getters['members:hasClass'](this.member);
|
||||
},
|
||||
isBuffed () {
|
||||
return this.$store.getters['members:isBuffed'](this.member);
|
||||
},
|
||||
visualBuffs () {
|
||||
return {
|
||||
snowball: 'snowman',
|
||||
spookySparkles: 'ghost',
|
||||
shinySeed: `avatar_floral_${this.member.stats.class}`,
|
||||
seafoam: 'seafoam_star',
|
||||
};
|
||||
},
|
||||
skinClass () {
|
||||
const baseClass = `skin_${this.member.preferences.skin}`;
|
||||
|
||||
return `${baseClass}${this.member.preferences.sleep ? '_sleep' : ''}`;
|
||||
},
|
||||
costumeClass () {
|
||||
return this.member.preferences.costume ? 'costume' : 'equipped';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getGearClass (gearType) {
|
||||
let result = this.member.items.gear[this.costumeClass][gearType];
|
||||
|
||||
if (this.overrideAvatarGear && this.overrideAvatarGear[gearType]) {
|
||||
result = this.overrideAvatarGear[gearType];
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
showAvatar () {
|
||||
if (!this.showVisualBuffs) return true;
|
||||
|
||||
const { buffs } = this.member.stats;
|
||||
|
||||
return !buffs.snowball && !buffs.spookySparkles && !buffs.shinySeed && !buffs.seafoam;
|
||||
},
|
||||
getHairClass (type) {
|
||||
const hairPref = this.member.preferences.hair;
|
||||
|
||||
return `hair_${type}_${hairPref[type]}_${hairPref.color}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -499,7 +499,7 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
groupId () {
|
||||
// @TOOD: We might not need this since groupId is computed now
|
||||
// @TODO: We might not need this since groupId is computed now
|
||||
this.getMembers();
|
||||
},
|
||||
challengeId () {
|
||||
@@ -536,13 +536,12 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
sendMessage (member) {
|
||||
this.$root.$emit('habitica::new-inbox-message', {
|
||||
userIdToMessage: member._id,
|
||||
displayName: member.profile.name,
|
||||
username: member.auth.local.username,
|
||||
backer: member.backer,
|
||||
contributor: member.contributor,
|
||||
this.$store.dispatch('user:newPrivateMessageTo', {
|
||||
member,
|
||||
});
|
||||
|
||||
this.$root.$emit('bv::hide::modal', 'members-modal');
|
||||
this.$router.push('/private-messages');
|
||||
},
|
||||
async searchMembers (searchTerm = '') {
|
||||
this.members = await this.$store.state.memberModalOptions.fetchMoreMembers({
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div
|
||||
id="app-header"
|
||||
class="row"
|
||||
:class="{'hide-header': $route.name === 'groupPlan'}"
|
||||
:class="{'hide-header': hideHeader}"
|
||||
>
|
||||
<members-modal :hide-badge="true" />
|
||||
<member-details
|
||||
@@ -171,6 +171,9 @@ export default {
|
||||
sortedPartyMembers () {
|
||||
return orderBy(this.partyMembers, [this.user.party.order], [this.user.party.orderAscending]);
|
||||
},
|
||||
hideHeader () {
|
||||
return ['groupPlan', 'privateMessages'].includes(this.$route.name);
|
||||
},
|
||||
},
|
||||
created () {
|
||||
if (this.user.party && this.user.party._id) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<inbox-modal />
|
||||
<creator-intro />
|
||||
<profileModal />
|
||||
<report-flag-modal />
|
||||
@@ -408,6 +407,7 @@
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/utils.scss';
|
||||
@import '~@/assets/scss/variables.scss';
|
||||
|
||||
@media only screen and (max-width: 1200px) {
|
||||
.chevron {
|
||||
@@ -438,7 +438,7 @@
|
||||
}
|
||||
|
||||
.topbar {
|
||||
max-height: 56px;
|
||||
max-height: $menuToolbarHeight;
|
||||
|
||||
.currency-tray {
|
||||
margin-left: auto;
|
||||
@@ -721,7 +721,6 @@ import chevronDownIcon from '@/assets/svg/chevron-down.svg';
|
||||
import logo from '@/assets/svg/logo.svg';
|
||||
|
||||
import creatorIntro from '../creatorIntro';
|
||||
import InboxModal from '../userMenu/inbox.vue';
|
||||
import notificationMenu from './notificationsDropdown';
|
||||
import profileModal from '../userMenu/profileModal';
|
||||
import reportFlagModal from '../chat/reportFlagModal';
|
||||
@@ -733,7 +732,6 @@ import userDropdown from './userDropdown';
|
||||
export default {
|
||||
components: {
|
||||
creatorIntro,
|
||||
InboxModal,
|
||||
notificationMenu,
|
||||
profileModal,
|
||||
reportFlagModal,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
:can-remove="canRemove"
|
||||
:has-icon="true"
|
||||
:notification="notification"
|
||||
:read-after-click="true"
|
||||
:read-after-click="false"
|
||||
@click="action"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
props: ['notification', 'canRemove'],
|
||||
methods: {
|
||||
action () {
|
||||
this.$root.$emit('bv::show::modal', 'inbox-modal');
|
||||
this.$router.push('/private-messages');
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -139,7 +139,7 @@ import GROUP_TASK_CLAIMED from './notifications/groupTaskClaimed';
|
||||
import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints';
|
||||
import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems';
|
||||
import CARD_RECEIVED from './notifications/cardReceived';
|
||||
import NEW_INBOX_MESSAGE from './notifications/newInboxMessage';
|
||||
import NEW_INBOX_MESSAGE from './notifications/newPrivateMessage';
|
||||
import NEW_CHAT_MESSAGE from './notifications/newChatMessage';
|
||||
import WORLD_BOSS from './notifications/worldBoss';
|
||||
import VERIFY_USERNAME from './notifications/verifyUsername';
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<a
|
||||
class="nav-link dropdown-item
|
||||
dropdown-separated d-flex justify-content-between align-items-center"
|
||||
@click.prevent="showInbox()"
|
||||
@click.prevent="showPrivateMessages()"
|
||||
>
|
||||
<div>{{ $t('messages') }}</div>
|
||||
<message-count
|
||||
@@ -43,7 +43,7 @@
|
||||
</a>
|
||||
<a
|
||||
class="dropdown-item"
|
||||
@click="showAvatar('backgrounds', '2019')"
|
||||
@click="showAvatar('backgrounds', '2020')"
|
||||
>{{ $t('backgrounds') }}</a>
|
||||
<a
|
||||
class="dropdown-item"
|
||||
@@ -163,10 +163,15 @@ export default {
|
||||
this.$store.state.avatarEditorOptions.subpage = subpage;
|
||||
this.$root.$emit('bv::show::modal', 'avatar-modal');
|
||||
},
|
||||
showInbox () {
|
||||
showPrivateMessages () {
|
||||
markPMSRead(this.user);
|
||||
axios.post('/api/v4/user/mark-pms-read');
|
||||
this.$root.$emit('bv::show::modal', 'inbox-modal');
|
||||
|
||||
if (this.$router.history.current.name === 'privateMessages') {
|
||||
this.$root.$emit('pm::refresh');
|
||||
} else {
|
||||
this.$router.push('/private-messages');
|
||||
}
|
||||
},
|
||||
showProfile (startingPage) {
|
||||
this.$router.push({ name: startingPage });
|
||||
|
||||
@@ -341,7 +341,7 @@ export default {
|
||||
const { firstRender } = viewOptions;
|
||||
const { itemsInFirstPosition } = viewOptions;
|
||||
|
||||
// Render selected items in first postion only for the first render
|
||||
// Render selected items in first position only for the first render
|
||||
if (itemsInFirstPosition.indexOf(ownedItem.key) !== -1 && firstRender === false) {
|
||||
gearItemsByType[type].unshift(ownedItem);
|
||||
} else if (isEquipped === true && firstRender === true) {
|
||||
@@ -382,7 +382,7 @@ export default {
|
||||
const { firstRender } = viewOptions;
|
||||
const { itemsInFirstPosition } = viewOptions;
|
||||
|
||||
// Render selected items in first postion only for the first render
|
||||
// Render selected items in first position only for the first render
|
||||
if (itemsInFirstPosition.indexOf(ownedItem.key) !== -1 && firstRender === false) {
|
||||
gearItemsByClass[klass].unshift(ownedItem);
|
||||
} else if (isEquipped === true && firstRender === true) {
|
||||
|
||||
235
website/client/src/components/messages/conversationItem.vue
Normal file
235
website/client/src/components/messages/conversationItem.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div
|
||||
class="conversation"
|
||||
:class="{active: activeKey === uuid}"
|
||||
@click="$emit('click', {})"
|
||||
@mouseleave="hideDropDown()"
|
||||
>
|
||||
<div class="user">
|
||||
<user-label
|
||||
:backer="backer"
|
||||
:contributor="contributor"
|
||||
:name="displayName"
|
||||
/><span
|
||||
v-if="username"
|
||||
class="username"
|
||||
>@{{ username }}</span>
|
||||
<div
|
||||
v-if="lastMessageDate"
|
||||
class="time"
|
||||
>
|
||||
{{ lastMessageDate | timeAgo }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<div class="messagePreview">
|
||||
{{ lastMessageText }}
|
||||
</div>
|
||||
<div
|
||||
v-if="userLoggedIn.id !== uuid"
|
||||
class="actions"
|
||||
>
|
||||
<b-dropdown
|
||||
ref="dropdown"
|
||||
class="action-dropdown"
|
||||
right
|
||||
toggle-class="btn-flat action-padding"
|
||||
no-caret
|
||||
variant="link"
|
||||
size="lg"
|
||||
>
|
||||
<template slot="button-content">
|
||||
<div
|
||||
class="svg-icon inline dots"
|
||||
v-html="icons.dots"
|
||||
></div>
|
||||
</template>
|
||||
<b-dropdown-item @click="block()">
|
||||
<span class="dropdown-icon-item">
|
||||
<div
|
||||
class="svg-icon inline"
|
||||
v-html="icons.remove"
|
||||
></div><span class="text">{{ $t('block') }}</span></span>
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import userLabel from '../userLabel';
|
||||
|
||||
import dots from '@/assets/svg/dots.svg';
|
||||
import remove from '@/assets/svg/remove.svg';
|
||||
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
userLabel,
|
||||
},
|
||||
props: [
|
||||
'activeKey', 'uuid', 'backer', 'displayName',
|
||||
'username', 'contributor', 'lastMessageText',
|
||||
'lastMessageDate',
|
||||
],
|
||||
computed: {
|
||||
...mapState({
|
||||
userLoggedIn: 'user.data',
|
||||
}),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
dots,
|
||||
remove,
|
||||
}),
|
||||
};
|
||||
},
|
||||
filters: {
|
||||
timeAgo (value) {
|
||||
return moment(value).fromNow();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
hideDropDown () {
|
||||
const { dropdown } = this.$refs;
|
||||
|
||||
if (dropdown) {
|
||||
dropdown.hide();
|
||||
}
|
||||
},
|
||||
block () {
|
||||
this.$store.dispatch('user:block', {
|
||||
uuid: this.uuid,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.action-padding {
|
||||
height: 24px !important;
|
||||
width: 24px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.action-dropdown {
|
||||
.dropdown-item {
|
||||
padding: 12px 16px;
|
||||
|
||||
.svg-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dots {
|
||||
height: 16px;
|
||||
width: 4px;
|
||||
|
||||
svg path {
|
||||
fill: $purple-300
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.conversation {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid $gray-500;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background: #EEE;
|
||||
|
||||
.actions {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #f1edff;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 20px;
|
||||
|
||||
.user-label {
|
||||
flex: 1;
|
||||
flex-grow: 0;
|
||||
margin-right: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.username {
|
||||
flex: 1;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: 2;
|
||||
text-align: end;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.messagePreview {
|
||||
//width: 100%;
|
||||
height: 30px;
|
||||
margin-right: 40px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-stretch: normal;
|
||||
line-height: 1.33;
|
||||
letter-spacing: normal;
|
||||
color: $gray-100;
|
||||
overflow: hidden;
|
||||
|
||||
// text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
|
||||
.messagePreview {
|
||||
flex: 1;
|
||||
width: calc(100% - 16px);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: none;
|
||||
width: 16px;
|
||||
margin-top: 4px;
|
||||
|
||||
.dots {
|
||||
height: 16px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
216
website/client/src/components/messages/messageCard.vue
Normal file
216
website/client/src/components/messages/messageCard.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div class="card-body">
|
||||
<user-link
|
||||
:user-id="msg.uuid"
|
||||
:name="msg.user"
|
||||
:backer="msg.backer"
|
||||
:contributor="msg.contributor"
|
||||
/>
|
||||
<p class="time">
|
||||
<span
|
||||
v-if="msg.username"
|
||||
class="mr-1"
|
||||
>@{{ msg.username }}</span><span
|
||||
v-if="msg.username"
|
||||
class="mr-1"
|
||||
>•</span>
|
||||
<span
|
||||
v-b-tooltip.hover="messageDate"
|
||||
>{{ msg.timestamp | timeAgo }} </span>
|
||||
<span v-if="msg.client && user.contributor.level >= 4"> ({{ msg.client }})</span>
|
||||
</p>
|
||||
<div
|
||||
class="text"
|
||||
v-html="atHighlight(parseMarkdown(msg.text))"
|
||||
></div>
|
||||
<div
|
||||
v-if="isMessageReported"
|
||||
class="reported"
|
||||
>
|
||||
<span v-once>{{ $t('reportedMessage') }}</span><br>
|
||||
<span v-once>{{ $t('canDeleteNow') }}</span>
|
||||
</div>
|
||||
<hr>
|
||||
<div
|
||||
v-if="msg.id"
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
v-if="!isMessageReported"
|
||||
class="action d-flex align-items-center"
|
||||
@click="report(msg)"
|
||||
>
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon"
|
||||
v-html="icons.report"
|
||||
></div>
|
||||
<div v-once>
|
||||
{{ $t('report') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="action d-flex align-items-center"
|
||||
@click="remove()"
|
||||
>
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon"
|
||||
v-html="icons.delete"
|
||||
></div>
|
||||
<div v-once>
|
||||
{{ $t('delete') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.at-highlight {
|
||||
background-color: rgba(213, 200, 255, 0.32);
|
||||
padding: 0.1rem;
|
||||
}
|
||||
|
||||
.at-text {
|
||||
color: #6133b4;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/tiers.scss';
|
||||
|
||||
.action {
|
||||
display: inline-block;
|
||||
color: $gray-200;
|
||||
margin-right: 1em;
|
||||
font-size: 12px;
|
||||
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
color: $gray-300;
|
||||
margin-right: .2em;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
color: $purple-300;
|
||||
|
||||
.svg-icon {
|
||||
color: $purple-400;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: $gray-200;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
color: $gray-50;
|
||||
text-align: left !important;
|
||||
min-height: 0rem;
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.reported {
|
||||
margin-top: 18px;
|
||||
color: $red-50;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
|
||||
import habiticaMarkdown from 'habitica-markdown';
|
||||
import { mapState } from '@/libs/store';
|
||||
import userLink from '../userLink';
|
||||
|
||||
import deleteIcon from '@/assets/svg/delete.svg';
|
||||
import reportIcon from '@/assets/svg/report.svg';
|
||||
import { highlightUsers } from '../../libs/highlightUsers';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
userLink,
|
||||
},
|
||||
filters: {
|
||||
timeAgo (value) {
|
||||
return moment(value).fromNow();
|
||||
},
|
||||
},
|
||||
props: {
|
||||
msg: {},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
delete: deleteIcon,
|
||||
report: reportIcon,
|
||||
}),
|
||||
reported: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
isMessageReported () {
|
||||
return (this.msg.flags && this.msg.flags[this.user.id]) || this.reported;
|
||||
},
|
||||
messageDate () {
|
||||
const date = moment(this.msg.timestamp).toDate();
|
||||
return date.toString();
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.$emit('message-card-mounted');
|
||||
},
|
||||
methods: {
|
||||
report () {
|
||||
this.$root.$on('habitica:report-result', data => {
|
||||
if (data.ok) {
|
||||
this.reported = true;
|
||||
}
|
||||
|
||||
this.$root.$off('habitica:report-result');
|
||||
});
|
||||
|
||||
this.$root.$emit('habitica::report-chat', {
|
||||
message: this.msg,
|
||||
groupId: 'privateMessage',
|
||||
});
|
||||
},
|
||||
async remove () {
|
||||
if (!window.confirm(this.$t('areYouSureDeleteMessage'))) return;
|
||||
|
||||
const message = this.msg;
|
||||
this.$emit('message-removed', message);
|
||||
|
||||
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
|
||||
},
|
||||
atHighlight (text) {
|
||||
return highlightUsers(text, this.user.auth.local.username, this.user.profile.name);
|
||||
},
|
||||
parseMarkdown (text) {
|
||||
if (!text) return null;
|
||||
return habiticaMarkdown.render(String(text));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
400
website/client/src/components/messages/messageList.vue
Normal file
400
website/client/src/components/messages/messageList.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<perfect-scrollbar
|
||||
ref="container"
|
||||
class="container-fluid"
|
||||
:class="{'disable-perfect-scroll': disablePerfectScroll}"
|
||||
:options="psOptions"
|
||||
>
|
||||
<div class="row loadmore">
|
||||
<div v-if="canLoadMore && !isLoading">
|
||||
<div class="loadmore-divider"></div>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
@click="triggerLoad()"
|
||||
>
|
||||
{{ $t('loadEarlierMessages') }}
|
||||
</button>
|
||||
<div class="loadmore-divider"></div>
|
||||
</div>
|
||||
<h2
|
||||
v-show="isLoading"
|
||||
class="col-12 loading"
|
||||
>
|
||||
{{ $t('loading') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
v-for="(msg) in messages"
|
||||
:key="msg.id"
|
||||
class="row message-row"
|
||||
:class="{ 'margin-right': user._id !== msg.uuid}"
|
||||
>
|
||||
<div
|
||||
v-if="user._id !== msg.uuid"
|
||||
class="d-flex flex-grow-1"
|
||||
>
|
||||
<avatar
|
||||
v-if="msg.userStyles || (cachedProfileData[msg.uuid]
|
||||
&& !cachedProfileData[msg.uuid].rejected)"
|
||||
class="avatar-left"
|
||||
:member="msg.userStyles || cachedProfileData[msg.uuid]"
|
||||
:avatar-only="true"
|
||||
:override-top-padding="'14px'"
|
||||
:hide-class-badge="true"
|
||||
@click.native="showMemberModal(msg.uuid)"
|
||||
/>
|
||||
<div class="card card-right">
|
||||
<message-card
|
||||
:msg="msg"
|
||||
@message-removed="messageRemoved"
|
||||
@show-member-modal="showMemberModal"
|
||||
@message-card-mounted="itemWasMounted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="user._id === msg.uuid"
|
||||
class="d-flex flex-grow-1"
|
||||
>
|
||||
<div class="card card-left">
|
||||
<message-card
|
||||
:msg="msg"
|
||||
@message-removed="messageRemoved"
|
||||
@show-member-modal="showMemberModal"
|
||||
@message-card-mounted="itemWasMounted"
|
||||
/>
|
||||
</div>
|
||||
<avatar
|
||||
v-if="msg.userStyles
|
||||
|| (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)"
|
||||
class="avatar-right"
|
||||
:member="msg.userStyles || cachedProfileData[msg.uuid]"
|
||||
:avatar-only="true"
|
||||
:hide-class-badge="true"
|
||||
:override-top-padding="'14px'"
|
||||
@click.native="showMemberModal(msg.uuid)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</perfect-scrollbar>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~vue2-perfect-scrollbar/dist/vue2-perfect-scrollbar.css';
|
||||
|
||||
.disable-perfect-scroll {
|
||||
overflow-y: inherit !important;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 15%;
|
||||
min-width: 8rem;
|
||||
height: 120px;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.avatar-left {
|
||||
margin-left: -1rem;
|
||||
}
|
||||
|
||||
.avatar-right {
|
||||
margin-left: -1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 0px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0rem;
|
||||
width: 684px;
|
||||
}
|
||||
.message-row {
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
|
||||
&:not(.margin-right) {
|
||||
.d-flex {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 1200px) {
|
||||
.card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1400px) {
|
||||
.message-row {
|
||||
margin-left: -15px;
|
||||
margin-right: -30px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-left {
|
||||
border: 1px solid $purple-500;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
border: 1px solid $gray-500;
|
||||
}
|
||||
|
||||
.hr {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
border-bottom: 1px solid $gray-500;
|
||||
text-align: center;
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.hr-middle {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-family: 'Roboto Condensed';
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
color: $gray-200;
|
||||
background-color: $gray-700;
|
||||
padding: .2em;
|
||||
margin-top: .2em;
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.loadmore {
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
text-align: center;
|
||||
color: $gray-50;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loadmore-divider {
|
||||
height: 1px;
|
||||
background-color: $gray-500;
|
||||
flex: 1;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import axios from 'axios';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { PerfectScrollbar } from 'vue2-perfect-scrollbar';
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
import Avatar from '../avatar';
|
||||
import messageCard from './messageCard';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Avatar,
|
||||
messageCard,
|
||||
PerfectScrollbar,
|
||||
},
|
||||
props: {
|
||||
chat: {},
|
||||
isLoading: Boolean,
|
||||
canLoadMore: Boolean,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
currentDayDividerDisplay: moment().day(),
|
||||
cachedProfileData: {},
|
||||
currentProfileLoadedCount: 0,
|
||||
currentProfileLoadedEnd: 10,
|
||||
loading: false,
|
||||
handleScrollBack: false,
|
||||
lastOffset: -1,
|
||||
disablePerfectScroll: false,
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
this.loadProfileCache();
|
||||
|
||||
this.$el.addEventListener('selectstart', () => this.handleSelectStart());
|
||||
this.$el.addEventListener('mouseup', () => this.handleSelectChange());
|
||||
},
|
||||
created () {
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
beforeDestroy () {
|
||||
this.$el.removeEventListener('selectstart', () => this.handleSelectStart());
|
||||
this.$el.removeEventListener('mouseup', () => this.handleSelectChange());
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
// @TODO: We need a different lazy load mechnism.
|
||||
// But honestly, adding a paging route to chat would solve this
|
||||
messages () {
|
||||
this.loadProfileCache();
|
||||
return this.chat;
|
||||
},
|
||||
psOptions () {
|
||||
return {
|
||||
suppressScrollX: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleScroll () {
|
||||
this.loadProfileCache(window.scrollY / 1000);
|
||||
},
|
||||
async triggerLoad () {
|
||||
const container = this.$refs.container.$el;
|
||||
|
||||
// get current offset
|
||||
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
|
||||
// disable scroll
|
||||
// container.style.overflowY = 'hidden';
|
||||
|
||||
const canLoadMore = !this.isLoading && this.canLoadMore;
|
||||
|
||||
if (canLoadMore) {
|
||||
const triggerLoadResult = this.$emit('triggerLoad');
|
||||
|
||||
await triggerLoadResult;
|
||||
|
||||
this.handleScrollBack = true;
|
||||
}
|
||||
},
|
||||
loadProfileCache: debounce(function loadProfileCache (screenPosition) {
|
||||
this._loadProfileCache(screenPosition);
|
||||
}, 1000),
|
||||
async _loadProfileCache (screenPosition) {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
|
||||
const promises = [];
|
||||
const noProfilesLoaded = Object.keys(this.cachedProfileData).length === 0;
|
||||
|
||||
// @TODO: write an explination
|
||||
// @TODO: Remove this after enough messages are cached
|
||||
if (!noProfilesLoaded && screenPosition
|
||||
&& Math.floor(screenPosition) + 1 > this.currentProfileLoadedEnd / 10) {
|
||||
this.currentProfileLoadedEnd = 10 * (Math.floor(screenPosition) + 1);
|
||||
} else if (!noProfilesLoaded && screenPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aboutToCache = {};
|
||||
this.messages.forEach(message => {
|
||||
const { uuid } = message;
|
||||
|
||||
if (message.userStyles) {
|
||||
this.$set(this.cachedProfileData, uuid, message.userStyles);
|
||||
}
|
||||
|
||||
if (Boolean(uuid) && !this.cachedProfileData[uuid] && !aboutToCache[uuid]) {
|
||||
if (uuid === 'system' || this.currentProfileLoadedCount === this.currentProfileLoadedEnd) return;
|
||||
aboutToCache[uuid] = {};
|
||||
promises.push(axios.get(`/api/v4/members/${uuid}`));
|
||||
this.currentProfileLoadedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach(result => {
|
||||
// We could not load the user. Maybe they were deleted.
|
||||
// So, let's cache empty so we don't try again
|
||||
if (!result || !result.data || result.status >= 400) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = result.data.data;
|
||||
this.$set(this.cachedProfileData, userData._id, userData);
|
||||
});
|
||||
|
||||
// Merge in any attempts that were rejected so we don't attempt again
|
||||
for (const uuid in aboutToCache) {
|
||||
if (!this.cachedProfileData[uuid]) {
|
||||
this.$set(this.cachedProfileData, uuid, { rejected: true });
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
displayDivider (message) {
|
||||
if (this.currentDayDividerDisplay !== moment(message.timestamp).day()) {
|
||||
this.currentDayDividerDisplay = moment(message.timestamp).day();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
async showMemberModal (memberId) {
|
||||
let profile = this.cachedProfileData[memberId];
|
||||
|
||||
if (!profile._id) {
|
||||
const result = await this.$store.dispatch('members:fetchMember', { memberId });
|
||||
if (result.response && result.response.status === 404) {
|
||||
return this.$store.dispatch('snackbars:add', {
|
||||
title: 'Habitica',
|
||||
text: this.$t('messageDeletedUser'),
|
||||
type: 'error',
|
||||
timeout: false,
|
||||
});
|
||||
}
|
||||
this.cachedProfileData[memberId] = result.data.data;
|
||||
profile = result.data.data;
|
||||
}
|
||||
|
||||
// Open the modal only if the data is available
|
||||
if (profile && !profile.rejected) {
|
||||
this.$router.push({ name: 'userProfile', params: { userId: profile._id } });
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
itemWasMounted: debounce(function itemWasMounted () {
|
||||
if (this.handleScrollBack) {
|
||||
this.handleScrollBack = false;
|
||||
|
||||
const container = this.$refs.container.$el;
|
||||
const offset = container.scrollHeight - container.clientHeight;
|
||||
|
||||
const newOffset = offset + this.lastOffset;
|
||||
|
||||
container.scrollTo(0, newOffset);
|
||||
// enable scroll again
|
||||
// container.style.overflowY = 'scroll';
|
||||
}
|
||||
}, 50),
|
||||
messageRemoved (message) {
|
||||
this.$emit('message-removed', message);
|
||||
},
|
||||
handleSelectStart () {
|
||||
this.disablePerfectScroll = true;
|
||||
},
|
||||
handleSelectChange () {
|
||||
this.disablePerfectScroll = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -28,39 +28,38 @@
|
||||
<small>{{ $t('unsubscribeAllEmailsText') }}</small>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<table class="table"></table>
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>
|
||||
<span>{{ $t('email') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span>{{ $t('push') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="notification in notificationsIds"
|
||||
:key="notification"
|
||||
>
|
||||
<td>
|
||||
<span>{{ $t(notification) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
v-model="user.preferences.emailNotifications[notification]"
|
||||
type="checkbox"
|
||||
@change="set('emailNotifications', notification)"
|
||||
>
|
||||
</td>
|
||||
<td v-if="onlyEmailsIds.indexOf(notification) === -1">
|
||||
<input
|
||||
v-model="user.preferences.pushNotifications[notification]"
|
||||
type="checkbox"
|
||||
@change="set('pushNotifications', notification)"
|
||||
>
|
||||
<hr>
|
||||
</td>
|
||||
</tr>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>
|
||||
<span>{{ $t('email') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span>{{ $t('push') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
<tr v-for="notification in notificationsIds" :key="notification">
|
||||
<td>
|
||||
<span>{{ $t(notification) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
v-model="user.preferences.emailNotifications[notification]"
|
||||
type="checkbox"
|
||||
@change="set('emailNotifications', notification)"
|
||||
>
|
||||
</td>
|
||||
<td v-if="onlyEmailsIds.indexOf(notification) === -1">
|
||||
<input
|
||||
v-model="user.preferences.pushNotifications[notification]"
|
||||
type="checkbox"
|
||||
@change="set('pushNotifications', notification)"
|
||||
>
|
||||
<td v-else>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,6 +73,8 @@ export default {
|
||||
data () {
|
||||
return {
|
||||
notificationsIds: Object.freeze([
|
||||
'majorUpdates',
|
||||
'onboarding',
|
||||
'newPM',
|
||||
'wonChallenge',
|
||||
'giftedGems',
|
||||
@@ -85,8 +86,6 @@ export default {
|
||||
'invitedQuest',
|
||||
'importantAnnouncements',
|
||||
'weeklyRecaps',
|
||||
'onboarding',
|
||||
'majorUpdates',
|
||||
'subscriptionReminders',
|
||||
]),
|
||||
// list of email-only notifications
|
||||
|
||||
@@ -812,7 +812,8 @@ export default {
|
||||
this.localAuth.username = this.user.auth.local.username;
|
||||
this.user.flags.verifiedUsername = true;
|
||||
} else if (attribute === 'email') {
|
||||
this.user.auth.local.email = updates[attribute];
|
||||
this.user.auth.local.email = updates.newEmail;
|
||||
window.alert(this.$t('emailSuccess'));
|
||||
}
|
||||
},
|
||||
async changeDisplayName (newName) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<b-modal
|
||||
id="task-modal"
|
||||
:no-close-on-esc="showTagsSelect"
|
||||
:no-close-on-backdrop="showTagsSelect"
|
||||
:no-close-on-esc="true"
|
||||
:no-close-on-backdrop="true"
|
||||
size="sm"
|
||||
@hidden="onClose()"
|
||||
@show="handleOpen()"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { storiesOf } from '@storybook/vue';
|
||||
import { withKnobs, number } from '@storybook/addon-knobs';
|
||||
|
||||
import CountBadge from './countBadge.vue';
|
||||
|
||||
storiesOf('Count Badge', module)
|
||||
const stories = storiesOf('Count Badge', module);
|
||||
|
||||
stories.addDecorator(withKnobs);
|
||||
|
||||
stories
|
||||
.add('simple', () => ({
|
||||
components: { CountBadge },
|
||||
template: `
|
||||
@@ -19,9 +24,9 @@ storiesOf('Count Badge', module)
|
||||
<count-badge :count="count" :show="true"></count-badge>
|
||||
</div>
|
||||
`,
|
||||
data () {
|
||||
return {
|
||||
count: 3,
|
||||
};
|
||||
props: {
|
||||
count: {
|
||||
default: number('Count', 3),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<div class="popover-box">
|
||||
<div
|
||||
:id="containerId"
|
||||
class="clearfix"
|
||||
class="clearfix toggle-switch-outer"
|
||||
>
|
||||
<div
|
||||
v-if="label"
|
||||
class="float-left toggle-switch-description"
|
||||
:class="hoverText ? 'hasPopOver' : ''"
|
||||
>
|
||||
{{ label }}
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
<div class="toggle-switch float-left">
|
||||
<input
|
||||
@@ -53,9 +53,7 @@
|
||||
}
|
||||
|
||||
.toggle-switch-description {
|
||||
height: 20px;
|
||||
|
||||
&.hasPopOver {
|
||||
&.hasPopOver span {
|
||||
border-bottom: 1px dashed $gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
115
website/client/src/components/userLabel.vue
Normal file
115
website/client/src/components/userLabel.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="displayName"
|
||||
v-b-tooltip.hover.top="tierTitle"
|
||||
class="user-label"
|
||||
:class="levelStyle()"
|
||||
>
|
||||
{{ displayName }}
|
||||
<div
|
||||
class="svg-icon"
|
||||
v-html="tierIcon()"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.user-label.no-tier {
|
||||
color: $gray-50;
|
||||
}
|
||||
|
||||
.user-label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
|
||||
.svg-icon {
|
||||
width: 10px;
|
||||
display: inline-block;
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import achievementsLib from '@/../../common/script/libs/achievements';
|
||||
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
|
||||
import tier1 from '@/assets/svg/tier-1.svg';
|
||||
import tier2 from '@/assets/svg/tier-2.svg';
|
||||
import tier3 from '@/assets/svg/tier-3.svg';
|
||||
import tier4 from '@/assets/svg/tier-4.svg';
|
||||
import tier5 from '@/assets/svg/tier-5.svg';
|
||||
import tier6 from '@/assets/svg/tier-6.svg';
|
||||
import tier7 from '@/assets/svg/tier-7.svg';
|
||||
import tier8 from '@/assets/svg/tier-mod.svg';
|
||||
import tier9 from '@/assets/svg/tier-staff.svg';
|
||||
import tierNPC from '@/assets/svg/tier-npc.svg';
|
||||
|
||||
export default {
|
||||
mixins: [styleHelper],
|
||||
props: ['user', 'name', 'backer', 'contributor', 'hideTooltip'],
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
tier1,
|
||||
tier2,
|
||||
tier3,
|
||||
tier4,
|
||||
tier5,
|
||||
tier6,
|
||||
tier7,
|
||||
tier8,
|
||||
tier9,
|
||||
tierNPC,
|
||||
}),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
displayName () {
|
||||
if (this.name) {
|
||||
return this.name;
|
||||
}
|
||||
if (this.user && this.user.profile) {
|
||||
return this.user.profile.name;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
level () {
|
||||
if (this.contributor) {
|
||||
return this.contributor.level;
|
||||
} if (this.user && this.user.contributor) {
|
||||
return this.user.contributor.level;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
isNPC () {
|
||||
if (this.backer) {
|
||||
return this.backer.level;
|
||||
} if (this.user && this.user.backer) {
|
||||
return this.user.backer.level;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
tierIcon () {
|
||||
if (this.isNPC) {
|
||||
return this.icons.tierNPC;
|
||||
}
|
||||
return this.icons[`tier${this.level}`];
|
||||
},
|
||||
tierTitle () {
|
||||
return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || '';
|
||||
},
|
||||
levelStyle () {
|
||||
return this.userLevelStyleFromLevel(this.level, this.isNPC);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,698 +0,0 @@
|
||||
<template>
|
||||
<b-modal
|
||||
id="inbox-modal"
|
||||
title
|
||||
:hide-footer="true"
|
||||
size="lg"
|
||||
@shown="onModalShown"
|
||||
@hide="onModalHide"
|
||||
>
|
||||
<div
|
||||
slot="modal-header"
|
||||
class="header-wrap container align-items-center"
|
||||
>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-2">
|
||||
<div
|
||||
class="svg-icon envelope"
|
||||
v-html="icons.messageIcon"
|
||||
></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h2
|
||||
v-once
|
||||
class="text-center"
|
||||
>
|
||||
{{ $t('messages') }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4 offset-3">
|
||||
<toggle-switch
|
||||
class="float-right"
|
||||
:label="optTextSet.switchDescription"
|
||||
:checked="!user.inbox.optOut"
|
||||
:hover-text="optTextSet.popoverText"
|
||||
@change="toggleOpt()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<div class="close">
|
||||
<span
|
||||
class="svg-icon inline icon-10"
|
||||
aria-hidden="true"
|
||||
@click="close()"
|
||||
v-html="icons.svgClose"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4 sidebar">
|
||||
<div class="search-section">
|
||||
<b-form-input
|
||||
v-model="search"
|
||||
:placeholder="$t('search')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="filtersConversations.length === 0"
|
||||
class="empty-messages text-center"
|
||||
>
|
||||
<div
|
||||
class="svg-icon envelope"
|
||||
v-html="icons.messageIcon"
|
||||
></div>
|
||||
<h4 v-once>
|
||||
{{ $t('emptyMessagesLine1') }}
|
||||
</h4>
|
||||
<p v-if="!user.flags.chatRevoked">
|
||||
{{ $t('emptyMessagesLine2') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="filtersConversations.length > 0"
|
||||
class="conversations"
|
||||
>
|
||||
<div
|
||||
v-for="conversation in filtersConversations"
|
||||
:key="conversation.key"
|
||||
class="conversation"
|
||||
:class="{active: selectedConversation.key === conversation.key}"
|
||||
@click="selectConversation(conversation.key)"
|
||||
>
|
||||
<div>
|
||||
<h3 :class="userLevelStyle(conversation)">
|
||||
{{ conversation.name }}
|
||||
<div
|
||||
class="svg-icon"
|
||||
v-html="tierIcon(conversation)"
|
||||
></div>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="time">
|
||||
<span
|
||||
v-if="conversation.username"
|
||||
class="mr-1"
|
||||
>@{{ conversation.username }} •</span>
|
||||
<span v-if="conversation.date">{{ conversation.date | timeAgo }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="messagePreview"
|
||||
>
|
||||
{{ conversation.lastMessageText
|
||||
? removeTags(parseMarkdown(conversation.lastMessageText)) : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-8 messages d-flex flex-column justify-content-between">
|
||||
<div
|
||||
v-if="!selectedConversation.key"
|
||||
class="empty-messages text-center"
|
||||
>
|
||||
<div
|
||||
class="svg-icon envelope"
|
||||
v-html="icons.messageIcon"
|
||||
></div>
|
||||
<h4>{{ placeholderTexts.title }}</h4>
|
||||
<p v-html="placeholderTexts.description"></p>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedConversation && selectedConversationMessages.length === 0"
|
||||
class="empty-messages text-center"
|
||||
>
|
||||
<p>{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}</p>
|
||||
</div>
|
||||
<chat-messages
|
||||
v-if="selectedConversation && selectedConversationMessages.length > 0"
|
||||
ref="chatscroll"
|
||||
class="message-scroll"
|
||||
:chat="selectedConversationMessages"
|
||||
:inbox="true"
|
||||
:can-load-more="canLoadMore"
|
||||
:is-loading="messagesLoading"
|
||||
@message-removed="messageRemoved"
|
||||
@triggerLoad="infiniteScrollTrigger"
|
||||
/>
|
||||
<div
|
||||
v-if="user.inbox.optOut && selectedConversation.key"
|
||||
class="pm-disabled-caption text-center"
|
||||
>
|
||||
<h4>{{ $t('PMDisabledCaptionTitle') }}</h4>
|
||||
<p>{{ $t('PMDisabledCaptionText') }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedConversation.key && !user.flags.chatRevoked"
|
||||
class="new-message-row"
|
||||
>
|
||||
<textarea
|
||||
v-model="newMessage"
|
||||
maxlength="3000"
|
||||
@keyup.ctrl.enter="sendPrivateMessage()"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
@click="sendPrivateMessage()"
|
||||
>
|
||||
{{ $t('send') }}
|
||||
</button>
|
||||
<div class="row">
|
||||
<span class="ml-3">{{ currentLength }} / 3000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
#inbox-modal .modal-body {
|
||||
padding-top: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.header-wrap {
|
||||
padding: 0.5em;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0rem;
|
||||
|
||||
.svg-icon {
|
||||
width: 10px;
|
||||
display: inline-block;
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
.envelope {
|
||||
color: $gray-400 !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: $gray-700;
|
||||
min-height: 540px;
|
||||
padding: 0;
|
||||
|
||||
.search-section {
|
||||
padding: 1em;
|
||||
box-shadow: 0 1px 2px 0 rgba(26, 24, 29, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
position: relative;
|
||||
padding-left: 0;
|
||||
padding-bottom: 6em;
|
||||
height: 540px;
|
||||
}
|
||||
|
||||
.message-scroll {
|
||||
max-height: 500px;
|
||||
overflow-x: scroll;
|
||||
|
||||
@media (min-width: 992px) {
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.to-form input {
|
||||
width: 60%;
|
||||
display: inline-block;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.empty-messages {
|
||||
margin-top: 10em;
|
||||
color: $gray-400;
|
||||
padding: 1em;
|
||||
|
||||
h4 {
|
||||
color: $gray-400;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.envelope {
|
||||
width: 30px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-disabled-caption {
|
||||
|
||||
padding-top: 1em;
|
||||
background-color: $gray-700;
|
||||
z-index: 2;
|
||||
|
||||
h4, p {
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.new-message-row {
|
||||
background-color: $gray-700;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 88px;
|
||||
width: 100%;
|
||||
padding: 1em;
|
||||
|
||||
textarea {
|
||||
height: 80%;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
button {
|
||||
vertical-align: bottom;
|
||||
display: inline-block;
|
||||
box-shadow: none;
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.conversations {
|
||||
max-height: 400px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.conversation {
|
||||
padding: 1.5em;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.conversation.active {
|
||||
border: 1px solid $purple-400;
|
||||
}
|
||||
|
||||
.conversation:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: $gray-200;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.messagePreview {
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import moment from 'moment';
|
||||
import filter from 'lodash/filter';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import habiticaMarkdown from 'habitica-markdown';
|
||||
import axios from 'axios';
|
||||
import { mapState } from '@/libs/store';
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
import toggleSwitch from '@/components/ui/toggleSwitch';
|
||||
|
||||
import chatMessages from '../chat/chatMessages';
|
||||
import messageIcon from '@/assets/svg/message.svg';
|
||||
import svgClose from '@/assets/svg/close.svg';
|
||||
import tier1 from '@/assets/svg/tier-1.svg';
|
||||
import tier2 from '@/assets/svg/tier-2.svg';
|
||||
import tier3 from '@/assets/svg/tier-3.svg';
|
||||
import tier4 from '@/assets/svg/tier-4.svg';
|
||||
import tier5 from '@/assets/svg/tier-5.svg';
|
||||
import tier6 from '@/assets/svg/tier-6.svg';
|
||||
import tier7 from '@/assets/svg/tier-7.svg';
|
||||
import tier8 from '@/assets/svg/tier-mod.svg';
|
||||
import tier9 from '@/assets/svg/tier-staff.svg';
|
||||
import tierNPC from '@/assets/svg/tier-npc.svg';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
chatMessages,
|
||||
toggleSwitch,
|
||||
},
|
||||
filters: {
|
||||
timeAgo (value) {
|
||||
return moment(new Date(value)).fromNow();
|
||||
},
|
||||
},
|
||||
mixins: [styleHelper],
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
messageIcon,
|
||||
svgClose,
|
||||
tier1,
|
||||
tier2,
|
||||
tier3,
|
||||
tier4,
|
||||
tier5,
|
||||
tier6,
|
||||
tier7,
|
||||
tier8,
|
||||
tier9,
|
||||
tierNPC,
|
||||
}),
|
||||
displayCreate: true,
|
||||
selectedConversation: {},
|
||||
search: '',
|
||||
newMessage: '',
|
||||
showPopover: false,
|
||||
messages: [],
|
||||
messagesByConversation: {}, // cache {uuid: []}
|
||||
loadedConversations: [],
|
||||
loaded: false,
|
||||
messagesLoading: false,
|
||||
initiatedConversation: null,
|
||||
updateConversionsCounter: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
canLoadMore () {
|
||||
return this.selectedConversation && this.selectedConversation.canLoadMore;
|
||||
},
|
||||
conversations () {
|
||||
const inboxGroup = groupBy(this.loadedConversations, 'uuid');
|
||||
|
||||
// Add placeholder for new conversations
|
||||
if (this.initiatedConversation && this.initiatedConversation.uuid) {
|
||||
inboxGroup[this.initiatedConversation.uuid] = [{
|
||||
uuid: this.initiatedConversation.uuid,
|
||||
user: this.initiatedConversation.user,
|
||||
username: this.initiatedConversation.username,
|
||||
contributor: this.initiatedConversation.contributor,
|
||||
id: '',
|
||||
text: '',
|
||||
timestamp: new Date(),
|
||||
}];
|
||||
}
|
||||
// Create conversation objects
|
||||
const convos = [];
|
||||
for (const key of Object.keys(inboxGroup)) {
|
||||
const recentMessage = inboxGroup[key][0];
|
||||
|
||||
const convoModel = {
|
||||
key: recentMessage.uuid,
|
||||
// Handles case where from user sent
|
||||
// the only message or the to user sent the only message
|
||||
name: recentMessage.user,
|
||||
username: !recentMessage.text ? recentMessage.username : recentMessage.toUserName,
|
||||
date: recentMessage.timestamp,
|
||||
lastMessageText: recentMessage.text,
|
||||
canLoadMore: true,
|
||||
page: 0,
|
||||
};
|
||||
|
||||
convos.push(convoModel);
|
||||
}
|
||||
|
||||
return convos;
|
||||
},
|
||||
// Separate from selectedConversation which
|
||||
// is not computed so messages don't update automatically
|
||||
selectedConversationMessages () {
|
||||
// Vue-subscribe to changes
|
||||
const subScribeToUpdate = this.messagesLoading || this.updateConversionsCounter > -1;
|
||||
|
||||
const selectedConversationKey = this.selectedConversation.key;
|
||||
const selectedConversation = this.messagesByConversation[selectedConversationKey];
|
||||
this.messages = selectedConversation || []; // eslint-disable-line vue/no-side-effects-in-computed-properties, max-len
|
||||
|
||||
const ordered = orderBy(this.messages, [m => m.timestamp], ['asc']);
|
||||
|
||||
if (subScribeToUpdate) {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
filtersConversations () {
|
||||
// Vue-subscribe to changes
|
||||
const subScribeToUpdate = this.updateConversionsCounter > -1;
|
||||
|
||||
const filtered = subScribeToUpdate && !this.search
|
||||
? this.conversations
|
||||
: filter(
|
||||
this.conversations,
|
||||
conversation => conversation.name.toLowerCase().indexOf(this.search.toLowerCase()) !== -1,
|
||||
);
|
||||
|
||||
const ordered = orderBy(filtered, [o => moment(o.date).toDate()], ['desc']);
|
||||
|
||||
return ordered;
|
||||
},
|
||||
currentLength () {
|
||||
return this.newMessage.length;
|
||||
},
|
||||
placeholderTexts () {
|
||||
if (this.user.flags.chatRevoked) {
|
||||
return {
|
||||
title: this.$t('PMPlaceholderTitleRevoked'),
|
||||
description: this.$t('chatPrivilegesRevoked'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: this.$t('PMPlaceholderTitle'),
|
||||
description: this.$t('PMPlaceholderDescription'),
|
||||
};
|
||||
},
|
||||
optTextSet () {
|
||||
if (!this.user.inbox.optOut) {
|
||||
return {
|
||||
switchDescription: this.$t('PMReceive'),
|
||||
popoverText: this.$t('PMEnabledOptPopoverText'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
switchDescription: this.$t('PMReceive'),
|
||||
popoverText: this.$t('PMDisabledOptPopoverText'),
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.$root.$on('habitica::new-inbox-message', data => {
|
||||
this.$root.$emit('bv::show::modal', 'inbox-modal');
|
||||
|
||||
// Wait for messages to be loaded
|
||||
const unwatchLoaded = this.$watch('loaded', loaded => {
|
||||
if (!loaded) return;
|
||||
|
||||
const conversation = this.conversations.find(convo => convo.key === data.userIdToMessage);
|
||||
if (loaded) setImmediate(() => unwatchLoaded());
|
||||
|
||||
if (conversation) {
|
||||
this.selectConversation(data.userIdToMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.initiatedConversation = {
|
||||
uuid: data.userIdToMessage,
|
||||
user: data.displayName,
|
||||
username: data.username,
|
||||
backer: data.backer,
|
||||
contributor: data.contributor,
|
||||
};
|
||||
|
||||
this.selectConversation(data.userIdToMessage);
|
||||
}, { immediate: true });
|
||||
});
|
||||
},
|
||||
destroyed () {
|
||||
this.$root.$off('habitica::new-inbox-message');
|
||||
},
|
||||
methods: {
|
||||
async onModalShown () {
|
||||
this.loaded = false;
|
||||
|
||||
const conversationRes = await axios.get('/api/v4/inbox/conversations');
|
||||
this.loadedConversations = conversationRes.data.data;
|
||||
|
||||
this.loaded = true;
|
||||
},
|
||||
onModalHide () {
|
||||
// reset everything
|
||||
this.loadedConversations = [];
|
||||
this.loaded = false;
|
||||
this.initiatedConversation = null;
|
||||
this.messagesByConversation = {};
|
||||
this.selectedConversation = {};
|
||||
},
|
||||
messageRemoved (message) {
|
||||
const messages = this.messagesByConversation[this.selectedConversation.key];
|
||||
|
||||
const messageIndex = messages.findIndex(msg => msg.id === message.id);
|
||||
if (messageIndex !== -1) messages.splice(messageIndex, 1);
|
||||
if (this.selectedConversationMessages.length === 0) {
|
||||
this.initiatedConversation = {
|
||||
uuid: this.selectedConversation.key,
|
||||
user: this.selectedConversation.name,
|
||||
username: this.selectedConversation.username,
|
||||
backer: this.selectedConversation.backer,
|
||||
contributor: this.selectedConversation.contributor,
|
||||
};
|
||||
}
|
||||
},
|
||||
toggleClick () {
|
||||
this.displayCreate = !this.displayCreate;
|
||||
},
|
||||
toggleOpt () {
|
||||
this.$store.dispatch('user:togglePrivateMessagesOpt');
|
||||
},
|
||||
async selectConversation (key) {
|
||||
const convoFound = this.conversations.find(conversation => conversation.key === key);
|
||||
|
||||
this.selectedConversation = convoFound || {};
|
||||
|
||||
if (!this.messagesByConversation[this.selectedConversation.key]) {
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
Vue.nextTick(() => {
|
||||
if (!this.$refs.chatscroll) return;
|
||||
const chatscroll = this.$refs.chatscroll.$el;
|
||||
chatscroll.scrollTop = chatscroll.scrollHeight;
|
||||
});
|
||||
},
|
||||
sendPrivateMessage () {
|
||||
if (!this.newMessage) return;
|
||||
|
||||
const messages = this.messagesByConversation[this.selectedConversation.key];
|
||||
|
||||
messages.push({
|
||||
sent: true,
|
||||
text: this.newMessage,
|
||||
timestamp: new Date(),
|
||||
toUser: this.selectedConversation.name,
|
||||
toUserName: this.selectedConversation.username,
|
||||
toUserContributor: this.selectedConversation.contributor,
|
||||
toUserBacker: this.selectedConversation.backer,
|
||||
toUUID: this.selectedConversation.uuid,
|
||||
|
||||
id: '-1', // will be updated once the result is back
|
||||
likes: {},
|
||||
ownerId: this.user._id,
|
||||
uuid: this.user._id,
|
||||
fromUUID: this.user._id,
|
||||
user: this.user.profile.name,
|
||||
username: this.user.auth.local.username,
|
||||
contributor: this.user.contributor,
|
||||
backer: this.user.backer,
|
||||
});
|
||||
|
||||
// Remove the placeholder message
|
||||
if (
|
||||
this.initiatedConversation
|
||||
&& this.initiatedConversation.uuid === this.selectedConversation.key
|
||||
) {
|
||||
this.loadedConversations.unshift(this.initiatedConversation);
|
||||
this.initiatedConversation = null;
|
||||
}
|
||||
|
||||
this.selectedConversation.lastMessageText = this.newMessage;
|
||||
this.selectedConversation.date = new Date();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
if (!this.$refs.chatscroll) return;
|
||||
const chatscroll = this.$refs.chatscroll.$el;
|
||||
chatscroll.scrollTop = chatscroll.scrollHeight;
|
||||
});
|
||||
|
||||
this.$store.dispatch('members:sendPrivateMessage', {
|
||||
toUserId: this.selectedConversation.key,
|
||||
message: this.newMessage,
|
||||
}).then(response => {
|
||||
const newMessage = response.data.data.message;
|
||||
const messageToReset = messages[messages.length - 1];
|
||||
messageToReset.id = newMessage.id; // just set the id, all other infos already set
|
||||
Object.assign(messages[messages.length - 1], messageToReset);
|
||||
this.updateConversionsCounter += 1;
|
||||
});
|
||||
|
||||
this.newMessage = '';
|
||||
},
|
||||
close () {
|
||||
this.$root.$emit('bv::hide::modal', 'inbox-modal');
|
||||
},
|
||||
tierIcon (message) {
|
||||
const isNPC = Boolean(message.backer && message.backer.npc);
|
||||
if (isNPC) {
|
||||
return this.icons.tierNPC;
|
||||
}
|
||||
if (!message.contributor) return null;
|
||||
return this.icons[`tier${message.contributor.level}`];
|
||||
},
|
||||
removeTags (html) {
|
||||
const tmp = document.createElement('DIV');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
},
|
||||
parseMarkdown (text) {
|
||||
if (!text) return null;
|
||||
return habiticaMarkdown.render(String(text));
|
||||
},
|
||||
infiniteScrollTrigger () {
|
||||
// show loading and wait until the loadMore debounced
|
||||
// or else it would trigger on every scrolling-pixel (while not loading)
|
||||
if (this.canLoadMore) {
|
||||
this.messagesLoading = true;
|
||||
}
|
||||
|
||||
return this.loadMore();
|
||||
},
|
||||
loadMore () {
|
||||
this.selectedConversation.page += 1;
|
||||
return this.loadMessages();
|
||||
},
|
||||
async loadMessages () {
|
||||
this.messagesLoading = true;
|
||||
|
||||
const requestUrl = `/api/v4/inbox/paged-messages?conversation=${this.selectedConversation.key}&page=${this.selectedConversation.page}`;
|
||||
const res = await axios.get(requestUrl);
|
||||
const loadedMessages = res.data.data;
|
||||
|
||||
this.messagesByConversation[this.selectedConversation.key] = this.messagesByConversation[this.selectedConversation.key] || []; // eslint-disable-line max-len
|
||||
const loadedMessagesToAdd = loadedMessages
|
||||
.filter(m => this.messagesByConversation[this.selectedConversation.key].findIndex(mI => mI.id === m.id) === -1); // eslint-disable-line max-len
|
||||
this.messagesByConversation[this.selectedConversation.key].push(...loadedMessagesToAdd);
|
||||
|
||||
// only show the load more Button if the max count was returned
|
||||
this.selectedConversation.canLoadMore = loadedMessages.length === 10;
|
||||
this.messagesLoading = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -859,13 +859,12 @@ export default {
|
||||
window.history.replaceState(null, null, '');
|
||||
},
|
||||
sendMessage () {
|
||||
this.$root.$emit('habitica::new-inbox-message', {
|
||||
userIdToMessage: this.user._id,
|
||||
displayName: this.user.profile.name,
|
||||
username: this.user.auth.local.username,
|
||||
backer: this.user.backer,
|
||||
contributor: this.user.contributor,
|
||||
this.$store.dispatch('user:newPrivateMessageTo', {
|
||||
member: this.user,
|
||||
});
|
||||
|
||||
this.$router.push('/private-messages');
|
||||
this.$root.$emit('bv::hide::modal', 'profile');
|
||||
},
|
||||
getProgressDisplay () {
|
||||
// let currentLoginDay = Content.loginIncentives[this.user.loginIncentives];
|
||||
|
||||
@@ -29,7 +29,7 @@ export function setUpLogging () { // eslint-disable-line import/prefer-default-e
|
||||
Vue.config.errorHandler = (err, vm, info) => {
|
||||
console.error('Unhandled error in Vue.js code.');
|
||||
console.error('Error:', err);
|
||||
console.error('Component where it occured:', vm);
|
||||
console.error('Component where it occurred:', vm);
|
||||
console.error('Info:', info);
|
||||
|
||||
_LTracker.push({
|
||||
|
||||
@@ -165,7 +165,7 @@ export default {
|
||||
}
|
||||
|
||||
return null;
|
||||
// @TOOD: User.sync();
|
||||
// @TODO: User.sync();
|
||||
},
|
||||
castCancel () {
|
||||
this.potionClickMode = false;
|
||||
|
||||
867
website/client/src/pages/private-messages.vue
Normal file
867
website/client/src/pages/private-messages.vue
Normal file
@@ -0,0 +1,867 @@
|
||||
<template>
|
||||
<div id="private-message">
|
||||
<div class="floating-header-shadow"></div>
|
||||
<div class="header-bar d-flex w-100">
|
||||
<!-- changing w-25 would also need changes in .left-header.w-25 -->
|
||||
<div class="d-flex w-25 left-header">
|
||||
<div
|
||||
v-once
|
||||
class="mail-icon svg-icon"
|
||||
v-html="icons.mail"
|
||||
></div>
|
||||
<h2
|
||||
v-once
|
||||
class="flex-fill text-center mail-icon-label"
|
||||
>
|
||||
{{ $t('messages') }}
|
||||
</h2>
|
||||
<div class="placeholder svg-icon">
|
||||
<!-- placeholder -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex w-75 selected-conversion">
|
||||
<face-avatar
|
||||
v-if="selectedConversation.userStyles"
|
||||
:member="selectedConversation.userStyles"
|
||||
:class="selectedConversationFaceAvatarClass"
|
||||
/>
|
||||
<user-label
|
||||
:backer="selectedConversation.backer"
|
||||
:contributor="selectedConversation.contributor"
|
||||
:name="selectedConversation.name"
|
||||
:hide-tooltip="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex content">
|
||||
<div class="w-25 sidebar d-flex flex-column">
|
||||
<div class="disable-background">
|
||||
<toggle-switch
|
||||
:label="optTextSet.switchDescription"
|
||||
:checked="this.user.inbox.optOut"
|
||||
:hover-text="optTextSet.popoverText"
|
||||
@change="toggleOpt()"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-section">
|
||||
<b-form-input
|
||||
v-model="search"
|
||||
class="input-search"
|
||||
:placeholder="$t('search')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="filtersConversations.length === 0"
|
||||
class="empty-messages m-auto text-center empty-sidebar"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon envelope"
|
||||
v-html="icons.messageIcon"
|
||||
></div>
|
||||
<h4 v-once>
|
||||
{{ $t('emptyMessagesLine1') }}
|
||||
</h4>
|
||||
<p v-if="!user.flags.chatRevoked">
|
||||
{{ $t('emptyMessagesLine2') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="filtersConversations.length > 0"
|
||||
class="conversations"
|
||||
>
|
||||
<conversation-item
|
||||
v-for="conversation in filtersConversations"
|
||||
:key="conversation.key"
|
||||
:active-key="selectedConversation.key"
|
||||
:contributor="conversation.contributor"
|
||||
:backer="conversation.backer"
|
||||
:uuid="conversation.key"
|
||||
:display-name="conversation.name"
|
||||
:username="conversation.username"
|
||||
:last-message-date="conversation.date"
|
||||
:last-message-text="conversation.lastMessageText
|
||||
? removeTags(parseMarkdown(conversation.lastMessageText)) : ''"
|
||||
@click="selectConversation(conversation.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-75 messages-column d-flex flex-column align-items-center">
|
||||
<div
|
||||
v-if="!selectedConversation.key"
|
||||
class="empty-messages full-height m-auto text-center"
|
||||
>
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon envelope"
|
||||
v-html="icons.messageIcon"
|
||||
></div>
|
||||
<h4>{{ placeholderTexts.title }}</h4>
|
||||
<p v-html="placeholderTexts.description"></p>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedConversation.key && selectedConversationMessages.length === 0"
|
||||
class="empty-messages full-height mt-auto text-center"
|
||||
>
|
||||
<avatar
|
||||
v-if="selectedConversation.userStyles"
|
||||
:member="selectedConversation.userStyles"
|
||||
:avatar-only="true"
|
||||
sprites-margin="0 0 0 -45px"
|
||||
class="center-avatar"
|
||||
/>
|
||||
<h3>{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}</h3>
|
||||
<p>{{ $t('beginningOfConversationReminder') }}</p>
|
||||
</div>
|
||||
<messageList
|
||||
v-if="selectedConversation && selectedConversationMessages.length > 0"
|
||||
ref="chatscroll"
|
||||
class="message-scroll"
|
||||
:chat="selectedConversationMessages"
|
||||
:can-load-more="canLoadMore"
|
||||
:is-loading="messagesLoading"
|
||||
@message-removed="messageRemoved"
|
||||
@triggerLoad="infiniteScrollTrigger"
|
||||
/>
|
||||
<div
|
||||
v-if="user.inbox.optOut"
|
||||
class="pm-disabled-caption text-center"
|
||||
>
|
||||
<h4>{{ $t('PMDisabledCaptionTitle') }}</h4>
|
||||
<p>{{ $t('PMDisabledCaptionText') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="selectedConversation.key && !user.flags.chatRevoked"
|
||||
class="new-message-row d-flex align-items-center"
|
||||
>
|
||||
<textarea
|
||||
v-model="newMessage"
|
||||
class="flex-fill"
|
||||
:placeholder="$t('needsTextPlaceholder')"
|
||||
maxlength="3000"
|
||||
:class="{'has-content': newMessage !== ''}"
|
||||
@keyup.ctrl.enter="sendPrivateMessage()"
|
||||
></textarea>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedConversation.key && !user.flags.chatRevoked"
|
||||
class="sub-new-message-row d-flex"
|
||||
>
|
||||
<div
|
||||
v-once
|
||||
class="guidelines flex-fill"
|
||||
v-html="$t('communityGuidelinesIntro')"
|
||||
></div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:class="{'disabled':newMessage === ''}"
|
||||
@click="sendPrivateMessage()"
|
||||
>
|
||||
{{ $t('send') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/variables.scss';
|
||||
|
||||
$pmHeaderHeight: 56px;
|
||||
|
||||
// Content of Private Message should be always full-size (minus the toolbar/resting banner)
|
||||
|
||||
#private-message {
|
||||
height: calc(100vh - #{$menuToolbarHeight} -
|
||||
var(--banner-gifting-height, 0px) -
|
||||
var(--banner-resting-height, 0px)); // css variable magic :), must be 0px, 0 alone won't work
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
height: calc(100vh - #{$menuToolbarHeight} - #{$pmHeaderHeight} -
|
||||
var(--banner-gifting-height, 0px) -
|
||||
var(--banner-resting-height, 0px)
|
||||
);
|
||||
}
|
||||
|
||||
.disable-background {
|
||||
.toggle-switch-description {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toggle-switch-outer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 0rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 66vw;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
margin: 10vh 15vw 0rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 0rem;
|
||||
|
||||
.close {
|
||||
cursor: pointer;
|
||||
margin: 0rem 1.5rem;
|
||||
min-width: 0.75rem;
|
||||
padding: 0rem;
|
||||
width: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-switch-description {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-stretch: normal;
|
||||
line-height: 1.43;
|
||||
letter-spacing: normal;
|
||||
color: $gray-50;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/tiers.scss';
|
||||
@import '~@/assets/scss/variables.scss';
|
||||
|
||||
$pmHeaderHeight: 56px;
|
||||
$background: $white;
|
||||
|
||||
.header-bar {
|
||||
height: 56px;
|
||||
background-color: $white;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
align-items: center;
|
||||
|
||||
.mail-icon {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.mail-icon-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.placeholder.svg-icon {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.left-header.w-25 {
|
||||
width: calc(25% - 2rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.full-height {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-label {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.input-search {
|
||||
background-repeat: no-repeat;
|
||||
background-position: center left 16px;
|
||||
background-size: 16px 16px;
|
||||
background-image: url(~@/assets/svg/for-css/search_gray.svg) !important;
|
||||
padding-left: 40px;
|
||||
|
||||
color: $gray-200 !important;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.selected-conversion {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#private-message {
|
||||
background-color: $background;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.disable-background {
|
||||
height: 44px;
|
||||
background-color: $gray-600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
.conversations {
|
||||
max-height: 35rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-messages {
|
||||
h3, h4, p {
|
||||
color: $gray-400;
|
||||
margin: 0rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.envelope {
|
||||
width: 30px;
|
||||
margin: 0 auto 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.envelope {
|
||||
color: $gray-500 !important;
|
||||
margin: 0rem;
|
||||
max-width: 2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0rem;
|
||||
|
||||
.svg-icon {
|
||||
width: 10px;
|
||||
display: inline-block;
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
.header-wrap {
|
||||
padding: 0.5em;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.messagePreview {
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.messages-column {
|
||||
padding: 0rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.empty-messages, .message-scroll {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.message-scroll {
|
||||
overflow-x: hidden;
|
||||
padding-top: 0.5rem;
|
||||
|
||||
@media (min-width: 992px) {
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.new-message-row {
|
||||
width: 100%;
|
||||
padding-left: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
|
||||
textarea {
|
||||
height: 5.5rem;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
border-radius: 2px;
|
||||
z-index: 5;
|
||||
border: solid 1px $gray-400;
|
||||
opacity: 0.64;
|
||||
background-color: $gray-500;
|
||||
|
||||
&:focus, &.has-content {
|
||||
opacity: 1;
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sub-new-message-row {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
|
||||
.guidelines {
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-stretch: normal;
|
||||
line-height: 1.33;
|
||||
letter-spacing: normal;
|
||||
color: $gray-200;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 40px;
|
||||
border-radius: 2px;
|
||||
margin-left: 1.5rem;
|
||||
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
opacity: 0.64;
|
||||
background-color: $gray-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pm-disabled-caption {
|
||||
padding-top: 1em;
|
||||
background-color: $gray-700;
|
||||
z-index: 2;
|
||||
|
||||
h4, p {
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: $gray-700;
|
||||
min-height: 540px;
|
||||
max-width: 330px;
|
||||
padding: 0;
|
||||
border-bottom-left-radius: 8px;
|
||||
|
||||
.search-section {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid $gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: $gray-200;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.to-form input {
|
||||
width: 60%;
|
||||
display: inline-block;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.empty-sidebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.floating-message-input {
|
||||
background: $background;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.floating-header-shadow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
|
||||
box-shadow: 0 3px 12px 0 rgba(26, 24, 29, 0.24);
|
||||
}
|
||||
|
||||
.center-avatar {
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import moment from 'moment';
|
||||
import filter from 'lodash/filter';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import habiticaMarkdown from 'habitica-markdown';
|
||||
import axios from 'axios';
|
||||
import { mapState } from '@/libs/store';
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
import toggleSwitch from '@/components/ui/toggleSwitch';
|
||||
import userLabel from '@/components/userLabel';
|
||||
|
||||
import messageList from '@/components/messages/messageList';
|
||||
import messageIcon from '@/assets/svg/message.svg';
|
||||
import mail from '@/assets/svg/mail.svg';
|
||||
import conversationItem from '@/components/messages/conversationItem';
|
||||
import faceAvatar from '@/components/faceAvatar';
|
||||
import Avatar from '@/components/avatar';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Avatar,
|
||||
messageList,
|
||||
toggleSwitch,
|
||||
conversationItem,
|
||||
userLabel,
|
||||
faceAvatar,
|
||||
},
|
||||
filters: {
|
||||
timeAgo (value) {
|
||||
return moment(new Date(value)).fromNow();
|
||||
},
|
||||
},
|
||||
mixins: [styleHelper],
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
messageIcon,
|
||||
mail,
|
||||
}),
|
||||
displayCreate: true,
|
||||
selectedConversation: {},
|
||||
search: '',
|
||||
newMessage: '',
|
||||
showPopover: false,
|
||||
messages: [],
|
||||
messagesByConversation: {}, // cache {uuid: []}
|
||||
loadedConversations: [],
|
||||
loaded: false,
|
||||
messagesLoading: false,
|
||||
initiatedConversation: null,
|
||||
updateConversationsCounter: 0,
|
||||
};
|
||||
},
|
||||
async mounted () {
|
||||
this.$root.$on('pm::refresh', async () => {
|
||||
await this.reload();
|
||||
|
||||
this.selectConversation(this.loadedConversations[0].uuid, true);
|
||||
});
|
||||
|
||||
await this.reload();
|
||||
|
||||
const data = this.$store.state.privateMessageOptions;
|
||||
|
||||
if (data && data.userIdToMessage) {
|
||||
this.initiatedConversation = {
|
||||
uuid: data.userIdToMessage,
|
||||
user: data.displayName,
|
||||
username: data.username,
|
||||
backer: data.backer,
|
||||
contributor: data.contributor,
|
||||
userStyles: data.userStyles,
|
||||
};
|
||||
|
||||
this.$store.state.privateMessageOptions = {};
|
||||
|
||||
this.selectConversation(this.initiatedConversation.uuid);
|
||||
}
|
||||
},
|
||||
destroyed () {
|
||||
this.$root.$off('habitica::new-private-message');
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
canLoadMore () {
|
||||
return this.selectedConversation && this.selectedConversation.canLoadMore;
|
||||
},
|
||||
conversations () {
|
||||
const inboxGroup = groupBy(this.loadedConversations, 'uuid');
|
||||
|
||||
// Add placeholder for new conversations
|
||||
if (this.initiatedConversation && this.initiatedConversation.uuid) {
|
||||
inboxGroup[this.initiatedConversation.uuid] = [{
|
||||
uuid: this.initiatedConversation.uuid,
|
||||
user: this.initiatedConversation.user,
|
||||
username: this.initiatedConversation.username,
|
||||
contributor: this.initiatedConversation.contributor,
|
||||
backer: this.initiatedConversation.backer,
|
||||
userStyles: this.initiatedConversation.userStyles,
|
||||
id: '',
|
||||
text: '',
|
||||
timestamp: new Date(),
|
||||
}];
|
||||
}
|
||||
// Create conversation objects
|
||||
const convos = [];
|
||||
for (const key in inboxGroup) {
|
||||
if (Object.prototype.hasOwnProperty.call(inboxGroup, key)) {
|
||||
const recentMessage = inboxGroup[key][0];
|
||||
|
||||
const convoModel = {
|
||||
key: recentMessage.uuid,
|
||||
name: recentMessage.user,
|
||||
// Handles case where from user sent the only message
|
||||
// or the to user sent the only message
|
||||
username: recentMessage.username,
|
||||
date: recentMessage.timestamp,
|
||||
lastMessageText: recentMessage.text,
|
||||
contributor: recentMessage.contributor,
|
||||
userStyles: recentMessage.userStyles,
|
||||
backer: recentMessage.backer,
|
||||
canLoadMore: false,
|
||||
page: 0,
|
||||
};
|
||||
|
||||
convos.push(convoModel);
|
||||
}
|
||||
}
|
||||
|
||||
return convos;
|
||||
},
|
||||
// Separate from selectedConversation which is not computed
|
||||
// so messages don't update automatically
|
||||
/* eslint-disable vue/no-side-effects-in-computed-properties */
|
||||
selectedConversationMessages () {
|
||||
// Vue-subscribe to changes
|
||||
const subscribeToUpdate = this.messagesLoading || this.updateConversationsCounter > -1;
|
||||
|
||||
const selectedConversationKey = this.selectedConversation.key;
|
||||
const selectedConversation = this.messagesByConversation[selectedConversationKey];
|
||||
this.messages = selectedConversation || [];
|
||||
|
||||
const ordered = orderBy(this.messages, [m => m.timestamp], ['asc']);
|
||||
|
||||
if (subscribeToUpdate) {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
filtersConversations () {
|
||||
// Vue-subscribe to changes
|
||||
const subscribeToUpdate = this.updateConversationsCounter > -1;
|
||||
|
||||
const filtered = subscribeToUpdate && !this.search
|
||||
? this.conversations
|
||||
|
||||
/* eslint-disable max-len */
|
||||
: filter(this.conversations, conversation => conversation.name.toLowerCase().indexOf(this.search.toLowerCase()) !== -1);
|
||||
|
||||
const ordered = orderBy(filtered, [o => moment(o.date).toDate()], ['desc']);
|
||||
|
||||
return ordered;
|
||||
},
|
||||
currentLength () {
|
||||
return this.newMessage.length;
|
||||
},
|
||||
placeholderTexts () {
|
||||
if (this.user.flags.chatRevoked) {
|
||||
return {
|
||||
title: this.$t('PMPlaceholderTitleRevoked'),
|
||||
description: this.$t('chatPrivilegesRevoked'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: this.$t('PMPlaceholderTitle'),
|
||||
description: this.$t('PMPlaceholderDescription'),
|
||||
};
|
||||
},
|
||||
optTextSet () {
|
||||
if (!this.user.inbox.optOut) {
|
||||
return {
|
||||
switchDescription: this.$t('PMDisabled'),
|
||||
popoverText: this.$t('PMEnabledOptPopoverText'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
switchDescription: this.$t('PMDisabled'),
|
||||
popoverText: this.$t('PMDisabledOptPopoverText'),
|
||||
};
|
||||
},
|
||||
selectedConversationFaceAvatarClass () {
|
||||
if (this.selectedConversation && this.selectedConversation.contributor) {
|
||||
return `tier${this.selectedConversation.contributor.level}`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async reload () {
|
||||
this.loaded = false;
|
||||
|
||||
const conversationRes = await axios.get('/api/v4/inbox/conversations');
|
||||
this.loadedConversations = conversationRes.data.data;
|
||||
this.selectedConversation = {};
|
||||
|
||||
this.loaded = true;
|
||||
},
|
||||
messageRemoved (message) {
|
||||
const messages = this.messagesByConversation[this.selectedConversation.key];
|
||||
|
||||
const messageIndex = messages.findIndex(msg => msg.id === message.id);
|
||||
if (messageIndex !== -1) messages.splice(messageIndex, 1);
|
||||
if (this.selectedConversationMessages.length === 0) {
|
||||
this.initiatedConversation = {
|
||||
uuid: this.selectedConversation.key,
|
||||
user: this.selectedConversation.name,
|
||||
username: this.selectedConversation.username,
|
||||
backer: this.selectedConversation.backer,
|
||||
contributor: this.selectedConversation.contributor,
|
||||
};
|
||||
}
|
||||
},
|
||||
toggleClick () {
|
||||
this.displayCreate = !this.displayCreate;
|
||||
},
|
||||
toggleOpt () {
|
||||
this.$store.dispatch('user:togglePrivateMessagesOpt');
|
||||
},
|
||||
async selectConversation (key, forceLoadMessage = false) {
|
||||
const convoFound = this.conversations.find(conversation => conversation.key === key);
|
||||
|
||||
this.selectedConversation = convoFound || {};
|
||||
|
||||
if (!this.messagesByConversation[this.selectedConversation.key] || forceLoadMessage) {
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
Vue.nextTick(() => {
|
||||
if (!this.$refs.chatscroll) return;
|
||||
const chatscroll = this.$refs.chatscroll.$el;
|
||||
chatscroll.scrollTop = chatscroll.scrollHeight;
|
||||
});
|
||||
},
|
||||
sendPrivateMessage () {
|
||||
if (!this.newMessage) return;
|
||||
|
||||
const messages = this.messagesByConversation[this.selectedConversation.key];
|
||||
|
||||
messages.push({
|
||||
sent: true,
|
||||
text: this.newMessage,
|
||||
timestamp: new Date(),
|
||||
toUser: this.selectedConversation.name,
|
||||
toUserName: this.selectedConversation.username,
|
||||
toUserContributor: this.selectedConversation.contributor,
|
||||
toUserBacker: this.selectedConversation.backer,
|
||||
toUUID: this.selectedConversation.uuid,
|
||||
|
||||
id: '-1', // will be updated once the result is back
|
||||
likes: {},
|
||||
ownerId: this.user._id,
|
||||
uuid: this.user._id,
|
||||
fromUUID: this.user._id,
|
||||
user: this.user.profile.name,
|
||||
username: this.user.auth.local.username,
|
||||
contributor: this.user.contributor,
|
||||
backer: this.user.backer,
|
||||
});
|
||||
|
||||
// Remove the placeholder message
|
||||
if (this.initiatedConversation
|
||||
&& this.initiatedConversation.uuid === this.selectedConversation.key) {
|
||||
this.loadedConversations.unshift(this.initiatedConversation);
|
||||
this.initiatedConversation = null;
|
||||
}
|
||||
|
||||
this.selectedConversation.lastMessageText = this.newMessage;
|
||||
this.selectedConversation.date = new Date();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
if (!this.$refs.chatscroll) return;
|
||||
const chatscroll = this.$refs.chatscroll.$el;
|
||||
chatscroll.scrollTop = chatscroll.scrollHeight;
|
||||
});
|
||||
|
||||
this.$store.dispatch('members:sendPrivateMessage', {
|
||||
toUserId: this.selectedConversation.key,
|
||||
message: this.newMessage,
|
||||
}).then(response => {
|
||||
const newMessage = response.data.data.message;
|
||||
const messageToReset = messages[messages.length - 1];
|
||||
messageToReset.id = newMessage.id; // just set the id, all other infos already set
|
||||
Object.assign(messages[messages.length - 1], messageToReset);
|
||||
this.updateConversationsCounter += 1;
|
||||
});
|
||||
|
||||
this.newMessage = '';
|
||||
},
|
||||
removeTags (html) {
|
||||
const tmp = document.createElement('DIV');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
},
|
||||
parseMarkdown (text) {
|
||||
if (!text) return null;
|
||||
return habiticaMarkdown.render(String(text));
|
||||
},
|
||||
infiniteScrollTrigger () {
|
||||
// show loading and wait until the loadMore debounced
|
||||
// or else it would trigger on every scrolling-pixel (while not loading)
|
||||
if (this.canLoadMore) {
|
||||
this.messagesLoading = true;
|
||||
}
|
||||
|
||||
return this.loadMore();
|
||||
},
|
||||
loadMore () {
|
||||
this.selectedConversation.page += 1;
|
||||
return this.loadMessages();
|
||||
},
|
||||
async loadMessages () {
|
||||
this.messagesLoading = true;
|
||||
|
||||
// use local vars if the loading takes longer
|
||||
// and the user switches the conversation while loading
|
||||
const conversationKey = this.selectedConversation.key;
|
||||
|
||||
const requestUrl = `/api/v4/inbox/paged-messages?conversation=${conversationKey}&page=${this.selectedConversation.page}`;
|
||||
const res = await axios.get(requestUrl);
|
||||
const loadedMessages = res.data.data;
|
||||
|
||||
this.messagesByConversation[conversationKey] = this.messagesByConversation[conversationKey] || [];
|
||||
const loadedMessagesToAdd = loadedMessages
|
||||
.filter(m => this.messagesByConversation[conversationKey].findIndex(mI => mI.id === m.id) === -1);
|
||||
this.messagesByConversation[conversationKey].push(...loadedMessagesToAdd);
|
||||
|
||||
// only show the load more Button if the max count was returned
|
||||
this.selectedConversation.canLoadMore = loadedMessages.length === 10;
|
||||
this.messagesLoading = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -75,6 +75,8 @@ const GroupPlanIndex = () => import(/* webpackChunkName: "group-plans" */ '@/com
|
||||
const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation');
|
||||
const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing');
|
||||
|
||||
const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages');
|
||||
|
||||
// Challenges
|
||||
const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index');
|
||||
const MyChallenges = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/myChallenges');
|
||||
@@ -193,6 +195,7 @@ const router = new VueRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/private-messages', name: 'privateMessages', component: MessagesIndex },
|
||||
{
|
||||
name: 'challenges',
|
||||
path: '/challenges',
|
||||
|
||||
@@ -60,7 +60,10 @@ async function buyArmoire (store, params) {
|
||||
|
||||
const isExperience = item.type === 'experience';
|
||||
if (item.type === 'gear') {
|
||||
store.state.user.data.items.gear.owned[item.dropKey] = true;
|
||||
store.state.user.data.items.gear.owned = {
|
||||
...store.state.user.data.items.gear.owned,
|
||||
[item.dropKey]: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === 'food') {
|
||||
|
||||
@@ -160,3 +160,65 @@ export async function userLookup (store, params) {
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function block (store, params) {
|
||||
store.state.user.data.inbox.blocks.push(params.uuid);
|
||||
return axios.post(`/api/v4/user/block/${params.uuid}`);
|
||||
}
|
||||
|
||||
export function unblock (store, params) {
|
||||
const index = store.state.user.data.inbox.blocks.indexOf(params.uuid);
|
||||
store.state.user.data.inbox.blocks.splice(index, 1);
|
||||
return axios.post(`/api/v4/user/block/${params.uuid}`);
|
||||
}
|
||||
|
||||
export function newPrivateMessageTo (store, params) {
|
||||
const { member } = params;
|
||||
|
||||
const userStyles = {};
|
||||
userStyles.items = { gear: {} };
|
||||
|
||||
if (member.items) {
|
||||
userStyles.items.gear = {};
|
||||
userStyles.items.gear.costume = { ...member.items.gear.costume };
|
||||
userStyles.items.gear.equipped = { ...member.items.gear.equipped };
|
||||
|
||||
userStyles.items.currentMount = member.items.currentMount;
|
||||
userStyles.items.currentPet = member.items.currentPet;
|
||||
}
|
||||
|
||||
if (member.preferences) {
|
||||
userStyles.preferences = {};
|
||||
if (member.preferences.style) userStyles.preferences.style = member.preferences.style;
|
||||
userStyles.preferences.hair = member.preferences.hair;
|
||||
userStyles.preferences.skin = member.preferences.skin;
|
||||
userStyles.preferences.shirt = member.preferences.shirt;
|
||||
userStyles.preferences.chair = member.preferences.chair;
|
||||
userStyles.preferences.size = member.preferences.size;
|
||||
userStyles.preferences.chair = member.preferences.chair;
|
||||
userStyles.preferences.background = member.preferences.background;
|
||||
userStyles.preferences.costume = member.preferences.costume;
|
||||
}
|
||||
|
||||
if (member.stats) {
|
||||
userStyles.stats = {};
|
||||
userStyles.stats.class = member.stats.class;
|
||||
if (member.stats.buffs) {
|
||||
userStyles.stats.buffs = {
|
||||
seafoam: member.stats.buffs.seafoam,
|
||||
shinySeed: member.stats.buffs.shinySeed,
|
||||
spookySparkles: member.stats.buffs.spookySparkles,
|
||||
snowball: member.stats.buffs.snowball,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
store.state.privateMessageOptions = {
|
||||
userIdToMessage: member._id,
|
||||
displayName: member.profile.name,
|
||||
username: member.auth.local.username,
|
||||
backer: member.backer,
|
||||
contributor: member.contributor,
|
||||
userStyles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,6 +127,13 @@ export default function () {
|
||||
equipmentDrawerOpen: true,
|
||||
groupPlans: [],
|
||||
isRunningYesterdailies: false,
|
||||
privateMessageOptions: {
|
||||
userIdToMessage: '',
|
||||
displayName: '',
|
||||
username: '',
|
||||
backer: {},
|
||||
contributor: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ context('avatar.vue', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('returns if showing equiped gear', () => {
|
||||
it('returns if showing equipped gear', () => {
|
||||
expect(vm.costumeClass).to.equal('equipped');
|
||||
});
|
||||
it('returns if wearing a costume', () => {
|
||||
|
||||
@@ -116,6 +116,7 @@ module.exports = {
|
||||
},
|
||||
|
||||
devServer: {
|
||||
disableHostCheck: true,
|
||||
proxy: {
|
||||
// proxy all requests to the server at IP:PORT as specified in the top-level config
|
||||
'^/api/v3': {
|
||||
|
||||
9
website/client/webpack.webstorm.config
Normal file
9
website/client/webpack.webstorm.config
Normal file
@@ -0,0 +1,9 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.join(__dirname, 'src'),
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -34,5 +34,26 @@
|
||||
"achievementMonsterMagusModalText": "Posbíral(a) jsi všechny zombie zvířata!",
|
||||
"achievementMonsterMagusText": "Posbíral(a) všechny zombie mazlíčky.",
|
||||
"achievementMonsterMagus": "Monster mág",
|
||||
"achievementPartyOn": "Tvoje skupina se rozrostla na 4 členy!"
|
||||
"achievementPartyOn": "Tvoje skupina se rozrostla na 4 členy!",
|
||||
"achievementPearlyProModalText": "Zkrotil jste všechny Bílé zvířata!",
|
||||
"achievementPearlyProText": "Zkrotil všechny Bílé zvířata.",
|
||||
"achievementPrimedForPaintingModalText": "Shromáždili jste všechny Bílé mazlíčky!",
|
||||
"achievementPrimedForPaintingText": "Shromáždil všechny Bílé mazlíčky.",
|
||||
"achievementPrimedForPainting": "Základem pro vybarveni",
|
||||
"achievementPurchasedEquipmentModalText": "Vybavení je způsob, jak si přizpůsobit avatar a zlepšit své statistiky",
|
||||
"achievementPurchasedEquipmentText": "Koupil první kus vybavení.",
|
||||
"achievementPurchasedEquipment": "Nákup vybavení",
|
||||
"achievementFedPetModalText": "Existuje mnoho různých druhů potravin, ale domácí mazlíčci mohou být vybíraví",
|
||||
"achievementFedPetText": "Nakrmil/a svého prvního mazlíčka.",
|
||||
"achievementFedPet": "Nakrmte domácího mazlíčka",
|
||||
"achievementHatchedPetModalText": "Vydejte se do svého inventáře a zkuste zkombinovat lektvar a vejce",
|
||||
"achievementCompletedTaskModalText": "Zaškrtněte některý ze svých úkolů a získejte odměny",
|
||||
"achievementCompletedTaskText": "Dokončili svůj první úkol.",
|
||||
"achievementCompletedTask": "Dokončete úkol",
|
||||
"achievementCreatedTaskModalText": "Přidejte úkol pro něco, čeho byste chtěli tento týden dosáhnout",
|
||||
"achievementCreatedTaskText": "Vytvořili svůj první úkol.",
|
||||
"achievementCreatedTask": "Vytvořte úkol",
|
||||
"earnedAchievement": "Získali jste úspěch!",
|
||||
"viewAchievements": "Zobrazit úspěchy",
|
||||
"letsGetStarted": "Začněme!"
|
||||
}
|
||||
|
||||
@@ -437,5 +437,31 @@
|
||||
"backgroundDuckPondNotes": "Nakrm vodní ptáky u kachního jezírka.",
|
||||
"backgroundDuckPondText": "Kachní jezírko",
|
||||
"backgrounds032019": "Set 58: zveřejněno v březnu 2019",
|
||||
"backgroundValentinesDayFeastingHallNotes": "Pociť na Valentýna lásku v hodovní síni."
|
||||
"backgroundValentinesDayFeastingHallNotes": "Pociť na Valentýna lásku v hodovní síni.",
|
||||
"backgroundWinterNocturneText": "Zimní serenáda",
|
||||
"backgrounds122019": "SET 67: Vydáno v prosinci 2019",
|
||||
"backgroundPotionShopText": "Obchod lektvarů",
|
||||
"backgrounds112019": "SET 66: Vydáno v listopadu 2019",
|
||||
"backgroundPumpkinCarriageText": "Dýňový vozík",
|
||||
"backgrounds102019": "SET 65: Vydáno říjen 2019",
|
||||
"backgroundInAClassroomText": "Třída",
|
||||
"backgroundInAnAncientTombText": "Starověká hrobka",
|
||||
"backgroundAutumnFlowerGardenText": "Podzimní květinová zahrada",
|
||||
"backgrounds092019": "SET 64: Vydáno září 2019",
|
||||
"backgroundTreehouseText": "Dům na stromě",
|
||||
"backgroundGiantDandelionsText": "Obří pampelišky",
|
||||
"backgroundAmidAncientRuinsText": "Uprostřed ruin",
|
||||
"backgrounds082019": "SET 63: Vydáno srpen 2019",
|
||||
"backgroundAmongGiantAnemonesNotes": "Prozkoumejte útesový život, chráněný před predátory Mezi obříma sasankama.",
|
||||
"backgroundAmongGiantAnemonesText": "Mezi obříma sasankami",
|
||||
"backgroundFlyingOverTropicalIslandsNotes": "Nechte si vyrazit pohledem dech, při přeletu přes Tropické Ostrovy.",
|
||||
"backgroundFlyingOverTropicalIslandsText": "Létání nad tropickými ostrovy",
|
||||
"backgroundLakeWithFloatingLanternsText": "Jezero s plovoucími lucernami",
|
||||
"backgrounds072019": "SET 62: Vydáno červenec 2019",
|
||||
"backgroundUnderwaterVentsNotes": "Potopte se hluboko dolů, dolů k podvodním otvorům.",
|
||||
"backgroundUnderwaterVentsText": "Podvodní otvory",
|
||||
"backgroundSeasideCliffsNotes": "Stůjte na pláži s krásou Přímořské útesů nad vámi.",
|
||||
"backgroundSeasideCliffsText": "Přímořské útesy",
|
||||
"backgroundSchoolOfFishNotes": "Plavejte mezi školou ryb.",
|
||||
"backgroundSchoolOfFishText": "Škola ryb"
|
||||
}
|
||||
|
||||
@@ -351,5 +351,7 @@
|
||||
"questEggDolphinMountText": "Delfín",
|
||||
"questEggDolphinText": "Delfín",
|
||||
"hatchingPotionShadow": "Stín",
|
||||
"premiumPotionUnlimitedNotes": "Nepoužitelné na vejce z výprav."
|
||||
"premiumPotionUnlimitedNotes": "Nepoužitelné na vejce z výprav.",
|
||||
"hatchingPotionAurora": "Polární záře",
|
||||
"hatchingPotionAmber": "Jantar"
|
||||
}
|
||||
|
||||
@@ -55,5 +55,9 @@
|
||||
"workTodoProject": "Práce na projektu >> ukončena",
|
||||
"workDailyImportantTaskNotes": "Klikni k upřesnění tvého nejdůležitějšího úkolu",
|
||||
"workDailyImportantTask": "Nejdůležitější úkol >> pracoval(a) jsem na dnešním nejdůležitějším úkolu",
|
||||
"workHabitMail": "Zpracuj e-maily"
|
||||
"workHabitMail": "Zpracuj e-maily",
|
||||
"exerciseDailyText": "Protahování >> Každodenní cvičební rutina",
|
||||
"exerciseTodoText": "Nastavte rozvrh cvičení",
|
||||
"exerciseTodoNotes": "Klepnutím přidáte kontrolní seznam!",
|
||||
"healthHabit": "Jezte zdravé/nezdravé jídlo"
|
||||
}
|
||||
|
||||
@@ -331,5 +331,6 @@
|
||||
"getStarted": "Get Started!",
|
||||
"mobileApps": "Mobilní aplikace",
|
||||
"learnMore": "Zjisti více",
|
||||
"communityInstagram": "Instagram"
|
||||
"communityInstagram": "Instagram",
|
||||
"minPasswordLength": "Heslo musí mít 8 nebo více znaků."
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"weaponSpecialFall2019MageText": "Jednooká hůl",
|
||||
"weaponSpecialFall2019WarriorText": "Pařátový trojzubec",
|
||||
"weaponSpecialFall2019RogueText": "Notový pult",
|
||||
"weaponSpecialSummer2019HealerText": "Bublinová hůlka"
|
||||
"weaponSpecialSummer2019HealerText": "Bublinová hůlka",
|
||||
"armorSpecialSpring2019MageText": "Jantarová róba",
|
||||
"armorSpecialSpring2019RogueText": "Mrakové brnění",
|
||||
"weaponSpecialWinter2020RogueText": "Tyč lucerny"
|
||||
}
|
||||
|
||||
@@ -294,5 +294,6 @@
|
||||
"loadEarlierMessages": "Načíst dřívější zprávy",
|
||||
"demo": "Ukázka",
|
||||
"options": "Možnosti",
|
||||
"finish": "Dokončit"
|
||||
"finish": "Dokončit",
|
||||
"congratulations": "Gratulujeme!"
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
"dateEndJanuary": "Leden 31",
|
||||
"dateEndFebruary": "Únor 28",
|
||||
"winterPromoGiftHeader": "DARUJ PŘEDPLATNÉ A ZÍSKEJ JEDNO ZDARMA!",
|
||||
"winterPromoGiftDetails1": "Až do 15. ledna, když někomu darujete předplatné, získáte stejné předplatné pro sebe zdarma!",
|
||||
"winterPromoGiftDetails1": "Až do 6. ledna, když někomu darujete předplatné, získáte stejné předplatné pro sebe zdarma!",
|
||||
"winterPromoGiftDetails2": "Prosím, mějte však na paměti, že pokud ty nebo příjemce tvého dárku již máte probíhající předplatné, pak darované předplatné odstartuje až poté, co je stávající zrušeno, nebo až vyprší jeho platnost. Děkujeme ti moc za tvojí podporu! <3",
|
||||
"discountBundle": "balíček",
|
||||
"g1g1Announcement": "Darujte předplatné, získejte akci zdarma na předplatné!",
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"messageAuthEmailTaken": "Email se již používá",
|
||||
"messageAuthNoUserFound": "Uživatel nenalezen.",
|
||||
"messageAuthMustBeLoggedIn": "Musíš být přihlášen.",
|
||||
"messageAuthMustIncludeTokens": "Ve svém požadavku musíš uvést token a uid (uživatelské id)",
|
||||
"messageAuthMustIncludeTokens": "Ve svém požadavku musíš uvést token a uid (Uživatelské Id)",
|
||||
"messageGroupAlreadyInParty": "Již jsi ve skupině, zkus znovu načíst stránku.",
|
||||
"messageGroupOnlyLeaderCanUpdate": "Pouze velitel družiny může jí může upravovat!",
|
||||
"messageGroupRequiresInvite": "Nemůžeš se přidat do družiny, do které nejsi pozván.",
|
||||
@@ -62,5 +62,7 @@
|
||||
"unallocatedStatsPoints": "Máš <span class=\"notification-bold-blue\"><%= points %>nepřidělených vlastnostních bodů</span>",
|
||||
"beginningOfConversation": "Toto je začátek tvé konverzace s uživatelem <%= userName %>. Nezapomeň být milý, ucitvý a drž se směrnic komunity!",
|
||||
"messageDeletedUser": "Omlouváme se, ale tento uživatel smazal svůj účet.",
|
||||
"messageMissingDisplayName": "Missing display name."
|
||||
"messageMissingDisplayName": "Chybí zobrazované jméno.",
|
||||
"canDeleteNow": "Nyní můžete zprávu smazat.",
|
||||
"reportedMessage": "Tuto zprávu jste nahlásili moderátorům."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"welcomeToTavern": "Vítej v Krčmě!",
|
||||
"sleepDescription": "Potřebuješ pauzu? Ubytuj se v Danielově krčmě pro pauznutí některých z těžších herních mechanismů země Habitica:",
|
||||
"sleepBullet1": "Promeškané denní úkoly tě nezraní",
|
||||
"sleepBullet2": "Úkoly neztratí sérii a barva zůstane nezměněna",
|
||||
"sleepBullet2": "Úkoly neztratí sérii",
|
||||
"sleepBullet3": "Bossové ti neublíží za tvé vlastní zmeškané denní úkoly",
|
||||
"sleepBullet4": "Tvé poškození bossům nebo sbírka předmětů na Výpravě zůstanou vypnuty, dokud se z krčmy neodhlásíš",
|
||||
"pauseDailies": "Pauznout poškození",
|
||||
@@ -169,5 +169,6 @@
|
||||
"imReady": "Vstup do země Habitica",
|
||||
"limitedOffer": "Dostupné do <%= date %>",
|
||||
"paymentCanceledDisputes": "Na váš e-mail jsme zaslali potvrzení o zrušení. Pokud e-mail nevidíte, kontaktujte nás, abychom předešli budoucím sporům o fakturaci.",
|
||||
"paymentAutoRenew": "Toto předplatné se automaticky obnoví, dokud nebude zrušeno. Pokud potřebujete předplatné zrušit, můžete tak učinit z nastavení."
|
||||
"paymentAutoRenew": "Toto předplatné se automaticky obnoví, dokud nebude zrušeno. Pokud potřebujete předplatné zrušit, můžete tak učinit z nastavení.",
|
||||
"cannotUnpinItem": "Tuto položku nelze odepnout."
|
||||
}
|
||||
|
||||
@@ -639,5 +639,12 @@
|
||||
"questBronzeDropBronzePotion": "Bronzový líhnoucí lektvar",
|
||||
"questBronzeBoss": "Brazen Brouk",
|
||||
"questBronzeText": "Bitva s Brazenem Broukem",
|
||||
"mythicalMarvelsText": "Balíček mýtických zázraků"
|
||||
"mythicalMarvelsText": "Balíček mýtických zázraků",
|
||||
"questRobotCollectBolts": "Šrouby",
|
||||
"questRobotCollectGears": "Ozubená kola",
|
||||
"questRobotCollectSprings": "Pružiny",
|
||||
"questRobotDropRobotEgg": "Robot (Vejce)",
|
||||
"questAmberText": "Jantarová Aliance",
|
||||
"questAmberBoss": "Trerezin",
|
||||
"questAmberDropAmberPotion": "Jantarový líhnoucí lektvar"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"rebirthOrb": "Použil Kouli Znovozrození, aby začal znova, po dosáhnutí úrovně <%= level %>.",
|
||||
"rebirthOrb100": "Použil Kouli znovuzrození, aby začal odznovu po dosažení úrovně 100 nebo vyšší.",
|
||||
"rebirthOrbNoLevel": "Použil Kouli Znovozrození, aby začal znova.",
|
||||
"rebirthPop": "Obnoví tvou postavu a vrátí jí na 1. úroveň s povoláním Válečníka, zatímco ti zůstanou všechny úspěchy, celá sbírka a vybavení. Tvoje úkoly zůstanou i s historií, ale vrátí se na žlutou barvu. Tvé řady úspěchů se resetují, jen u úkolů z výzev zůstanou. Tvé zlato, zkušenosti, mana a efekty všech schopností budou odstraněny. Toto vše nastane s okamžitou platností. Pro více informací se podívej na wiki stránku: <a href='http://habitica.fandom.com/wiki/Orb_of_Rebirth' target='_blank'>Orb of Rebirth</a>.",
|
||||
"rebirthPop": "Obnoví tvou postavu a vrátí jí na 1. úroveň s povoláním Válečníka, zatímco ti zůstanou všechny úspěchy, celá sbírka a vybavení. Tvoje úkoly zůstanou i s historií, ale vrátí se na žlutou barvu. Tvé řady úspěchů se resetují, zůstanou úkoly patřící do aktivních výzev a do Skupiny. Tvé zlato, zkušenosti, mana a efekty všech schopností budou odstraněny. Toto vše nastane s okamžitou platností. Pro více informací se podívej na wiki stránku: <a href='http://habitica.fandom.com/wiki/Orb_of_Rebirth' target='_blank'>Orb of Rebirth</a>.",
|
||||
"rebirthName": "Koule znovuzrození",
|
||||
"reborn": "Znovuzrozen, maximální úroveň <%= reLevel %>",
|
||||
"confirmReborn": "Jsi si jistý?",
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"giftedSubscriptionFull": "Ahoj <%= username %>, <%= sender %> ti poslal <%= monthCount %> měsíce předplatného!",
|
||||
"giftedSubscriptionWinterPromo": "Hello <%= username %>, you received <%= monthCount %> months of subscription as part of our holiday gift-giving promotion!",
|
||||
"invitedParty": "Byli jste pozváni do Družiny",
|
||||
"invitedGuild": "Byli jste pozváni do Cechu",
|
||||
"invitedGuild": "Byli jste pozváni do Cechu",
|
||||
"importantAnnouncements": "Reminders to check in to complete tasks and receive prizes",
|
||||
"weeklyRecaps": "Shrnutí aktivity tvého účtu za poslední týden (Poznámka: momentálně vypnuto kvůli problémům s výkonem, ale doufáme že se to vyřeší a budeme brzy znovu posílat e-maily!)",
|
||||
"onboarding": "Guidance with setting up your Habitica account",
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
"missingUnsubscriptionCode": "Chybějící kód k zrušení předplatného.",
|
||||
"missingSubscription": "Uživatel nemá předplatné",
|
||||
"missingSubscriptionCode": "Chybějící kód předplatitele. Možné hodnoty: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.",
|
||||
"missingReceipt": "Missing Receipt.",
|
||||
"missingReceipt": "Chybějící potvrzení.",
|
||||
"cannotDeleteActiveAccount": "Máte aktivní předplatné, zrušte si ho před smazáním účtu.",
|
||||
"paymentNotSuccessful": "Zaplacení neproběhlo úspešně",
|
||||
"planNotActive": "Předplatné ještě nebylo aktivováén (vzhledem k PayPal bugu). Bude aktivováno <%= nextBillingDate %>, poté ho můžete zrušit a obdržíte veškeré výhody",
|
||||
|
||||
@@ -2,29 +2,66 @@
|
||||
"achievement": "Præstation",
|
||||
"share": "Del",
|
||||
"onwards": "Fremad!",
|
||||
"levelup": "Ved at opnå dine mål fra den virkelige verden er du steget i level, og er nu fuldt helet igen!",
|
||||
"reachedLevel": "Du har nået level <%= level %>",
|
||||
"achievementLostMasterclasser": "Quest færdiggører: Mesterklasse-rækken",
|
||||
"achievementLostMasterclasserText": "Færdiggjorde alle 16 quests i Mesterklasse quest-rækken og løste alle mysterier fra \"the Lost Masterclasser\"!",
|
||||
"achievementLostMasterclasserModalText": "Du har klaret alle seksten quests i Mesterklasser-serien og løst mysteriet om den forsvundne Mesterklasser!",
|
||||
"levelup": "Ved at opnå dine mål fra den virkelige verden er du steget i niveau, og er nu fuldt helet igen!",
|
||||
"reachedLevel": "Du har nået niveau <%= level %>",
|
||||
"achievementLostMasterclasser": "Quest-knuser: Mesterklasse-serien",
|
||||
"achievementLostMasterclasserText": "Færdiggjorde alle 16 quests i Mesterklasse-questrækken, og løste mysteriet om den Forsvundne Mesterklasser!",
|
||||
"achievementLostMasterclasserModalText": "Du har klaret alle seksten quests i Mesterklasse-serien og løst mysteriet om den Forsvundne Mesterklasser!",
|
||||
"achievementMindOverMatterText": "Har fuldført sten-, slim- og garn-kæledyrsquests.",
|
||||
"achievementMindOverMatterModalText": "Du har klaret sten-, slim-, og garn-kæledyrsquestene!",
|
||||
"achievementJustAddWater": "Tilføj kun vand",
|
||||
"achievementJustAddWaterText": "Har klaret blæksprutte-, søheste-, tiarmet blæksprutte-, hval-, skildpadde-, nøgensnegle-, søslange- og delfin-kæledyrsquestene.",
|
||||
"achievementJustAddWaterModalText": "Du har klaret blæksprutte-, søheste-, tiarmet blæksprutte-, hval-, skildpadde-, nøgensnegle-, søslange- og delfin-kæledyrsquestene!",
|
||||
"achievementBackToBasics": "Almindeligt udbredt",
|
||||
"achievementBackToBasicsText": "Har samlet alle Almindelige kæledyr.",
|
||||
"achievementBackToBasicsModalText": "Du har samlet alle Almindelige kæledyr!",
|
||||
"achievementBackToBasicsText": "Har samlet alle almindelige kæledyr.",
|
||||
"achievementBackToBasicsModalText": "Du har samlet alle almindelige kæledyr!",
|
||||
"achievementAllYourBase": "Alle almindelige",
|
||||
"achievementAllYourBaseText": "Har tæmmet alle Almindelige ridedyr.",
|
||||
"achievementAllYourBaseModalText": "Du har tæmmet alle Almindelige ridedyr!",
|
||||
"achievementMonsterMagusModalText": "Du har samlet all zombie dyr!",
|
||||
"achievementMonsterMagusText": "Har samlet all zombie dyr.",
|
||||
"achievementPartyOn": "Dit hold vokset til 4 medlemmer!",
|
||||
"achievementAridAuthorityModalText": "Du har tæmmet all ørken dyr!",
|
||||
"achievementAridAuthorityText": "Har tæmmet all ørken dyr.",
|
||||
"achievementDustDevilModalText": "Du har samlet alle ørken dyr!",
|
||||
"achievementDustDevilText": "Har samlet alle ørken dyr.",
|
||||
"achievementDustDevil": "Støv djævel",
|
||||
"achievementMindOverMatter": "Hvor der er vilje..."
|
||||
"achievementAllYourBaseText": "Har tæmmet alle almindelige ridedyr.",
|
||||
"achievementAllYourBaseModalText": "Du har tæmmet alle almindelige ridedyr!",
|
||||
"achievementMonsterMagusModalText": "Du har samlet all zombiekæledyr!",
|
||||
"achievementMonsterMagusText": "Har samlet all zombiekæledyr.",
|
||||
"achievementPartyOn": "Dit hold voksede til 4 medlemmer!",
|
||||
"achievementAridAuthorityModalText": "Du har tæmmet all ørkenridedyr!",
|
||||
"achievementAridAuthorityText": "Har tæmmet all ørkenridedyr.",
|
||||
"achievementDustDevilModalText": "Du har samlet alle ørkenkæledyr!",
|
||||
"achievementDustDevilText": "Har samlet alle ørkenkæledyr.",
|
||||
"achievementDustDevil": "Støvdjævel",
|
||||
"achievementMindOverMatter": "Stof til eftertanke",
|
||||
"achievementPearlyProModalText": "Du har tæmmet alle de hvide ridedyr!",
|
||||
"achievementPearlyProText": "Har tæmmet alle hvide ridedyr.",
|
||||
"achievementPearlyPro": "Perleskinnende prof",
|
||||
"achievementPrimedForPaintingModalText": "Du har samlet alle de hvide kæledyr!",
|
||||
"achievementPrimedForPaintingText": "Har samlet all hvide kæledyr.",
|
||||
"achievementPrimedForPainting": "Grundmalet gruppe",
|
||||
"achievementPurchasedEquipmentModalText": "Udstyr er en måde at tilpasse din avatar og forbedre dine egenskaber",
|
||||
"achievementPurchasedEquipmentText": "Købte deres første stykke udstyr.",
|
||||
"achievementPurchasedEquipment": "Køb udstyr",
|
||||
"achievementFedPetModalText": "Der er mange forskellige slags mad, men kæledyr kan være kræsne",
|
||||
"achievementFedPetText": "Fodrede deres første kæledyr.",
|
||||
"achievementFedPet": "Giv et kæledyr mad",
|
||||
"achievementHatchedPetModalText": "Kig i dit inventar, og prøv at kombinere en udrugningseliksir og et æg",
|
||||
"achievementHatchedPetText": "Udklækkede deres første kæledyr.",
|
||||
"achievementHatchedPet": "Udklæk et kæledyr",
|
||||
"achievementCompletedTaskModalText": "Marker enhver af dine opgaver som færdig for at få belønninger",
|
||||
"achievementCompletedTaskText": "Udførte deres første opgave.",
|
||||
"achievementCompletedTask": "Udfør en opgave",
|
||||
"achievementCreatedTaskModalText": "Tilføj en opgave for noget, du gerne vil gøre i denne uge",
|
||||
"achievementCreatedTaskText": "Oprettede deres første opgave.",
|
||||
"achievementCreatedTask": "Opret en opgave",
|
||||
"achievementUndeadUndertakerModalText": "Du har tæmmet alle zombieridedyr!",
|
||||
"achievementUndeadUndertakerText": "Har tæmmet alle zombieridedyr.",
|
||||
"achievementUndeadUndertaker": "Udød yndling",
|
||||
"achievementMonsterMagus": "Monstrøs magiker",
|
||||
"achievementKickstarter2019Text": "Sponsorerede Kickstarterprojektet for badges i 2019",
|
||||
"achievementKickstarter2019": "Pin Kickstarter sponsor",
|
||||
"achievementAridAuthority": "Knastør ekspert",
|
||||
"achievementPartyUp": "Du dannede hold med en anden bruger!",
|
||||
"hideAchievements": "Skjul <%= category %>",
|
||||
"showAllAchievements": "Vis alle <%= category %>",
|
||||
"onboardingCompleteDesc": "Du opnåede <strong>5 præstationer</strong> og <strong class=\"gold-amount\">100</strong> guld for at færdiggøre listen.",
|
||||
"earnedAchievement": "Du opnåede en præstation!",
|
||||
"viewAchievements": "Se præstationer",
|
||||
"letsGetStarted": "Lad os komme i gang!",
|
||||
"onboardingProgress": "<%= percentage %>% fremskridt",
|
||||
"gettingStartedDesc": "Lad os lave en opgave, udføre den, og så se på din belønning. You vil få <strong>5 præstationer</strong> og <strong class=\"gold-amount\">100 guld</strong>, når du er færdig!"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"defaultHabit1Text": "Produktivt arbejde (Klik på blyanten for at redigere)",
|
||||
"defaultHabit1Notes": "Eksempler på Gode Vaner: +Spist en grønsag +15 minutters produktivt arbejde",
|
||||
"defaultHabit1Notes": "Eksempler på gode vaner: + Spis en grønsag + 15 minutters produktivt arbejde",
|
||||
"defaultHabit2Text": "Spis junk food (Klik på blyanten for at redigere)",
|
||||
"defaultHabit2Notes": "Eksempler på Dårlige Vaner: -Ryge -Overspringshandling",
|
||||
"defaultHabit3Text": "Tag trapperne/elevator (Klik på blyanten for at redigere)",
|
||||
"defaultHabit3Notes": "Eksempel på God eller Dårlig Vane: +/- Tog Trapperne/Elevatoren; +/- Drak Vand/Sodavand",
|
||||
"defaultHabit2Notes": "Eksempler på dårlige vaner: - Rygning - Lav overspringshandlinger",
|
||||
"defaultHabit3Text": "Tag trapperne/elevatoren (Klik på blyanten for at redigere)",
|
||||
"defaultHabit3Notes": "Eksempel på en god eller dårlig vane: +/- Tog trapperne/elevatoren; +/- Drak vand/sodavand",
|
||||
"defaultHabit4Text": "Tilføj en opgave til Habitica",
|
||||
"defaultHabit4Notes": "Enten en Vane, en Daglig eller en To-Do",
|
||||
"defaultHabit5Text": "Tryk her for at gøre dette til en dårlig vane du gerne vil af med",
|
||||
"defaultHabit5Text": "Tryk her, for at gøre dette til en dårlig vane, du gerne vil af med",
|
||||
"defaultHabit5Notes": "Eller slet fra redigeringsskærmen",
|
||||
"defaultDaily1Text": "Brug Habitica til at holde styr på dine opgaver",
|
||||
"defaultTodo1Text": "Start med at spille Habitica (Markér mig som færdig!)",
|
||||
|
||||
@@ -140,59 +140,59 @@
|
||||
"subscriptionRateText": "Løbende $<%= price %> USD hver <%= months %>. måned",
|
||||
"recurringText": "løbende",
|
||||
"benefits": "Fordele",
|
||||
"coupon": "Kupon",
|
||||
"couponPlaceholder": "Indtast Kuponkode",
|
||||
"couponText": "Nogle gange har vi events og giver kuponkoder til specielt udstyr. (f.eks. til dem, der svinger forbi vores stand på Wondercon)",
|
||||
"coupon": "Rabat",
|
||||
"couponPlaceholder": "Indtast rabatkode",
|
||||
"couponText": "Nogle gange har vi events og giver rabatkoder til specielt udstyr (f.eks. til dem, der svinger indenom vores stand på Wondercon).",
|
||||
"apply": "Udfør",
|
||||
"resubscribe": "Genabonnér",
|
||||
"promoCode": "Promo-kode",
|
||||
"promoCodeApplied": "Promo-kode anvendt. Tjek dit inventar",
|
||||
"promoPlaceholder": "Indtast Promo-kode",
|
||||
"promoCodeApplied": "Promo-kode accepteret! Tjek dit inventar",
|
||||
"promoPlaceholder": "Indtast promo-kode",
|
||||
"displayInviteToPartyWhenPartyIs1": "Vis Invitér til Hold-knap når holdet har 1 medlem.",
|
||||
"saveCustomDayStart": "Gem Brugerdefineret Dagstart",
|
||||
"saveCustomDayStart": "Gem brugerdefineret starttidspunkt",
|
||||
"registration": "Registrering",
|
||||
"addLocalAuth": "Tilføj login med email og kodeord",
|
||||
"generateCodes": "Generér Koder",
|
||||
"generateCodes": "Generér koder",
|
||||
"generate": "Generér",
|
||||
"getCodes": "Hent Koder",
|
||||
"getCodes": "Hent koder",
|
||||
"webhooks": "Webhooks",
|
||||
"webhooksInfo": "Habitica tilbyder webhooks, så information kan blive sendt til et script på en anden hjemmeside, når der sker bestemte ting med din konto. Du kan specificere disse scripts her. Vær forsigtig med denne funktion. En forkert URL kan forårsage fejl eller gøre Habitica langsomt. Se wiki-siden <a target=\"_blank\" href=\"https://habitica.fandom.com/wiki/Webhooks\">Webhooks</a> for mere information (engelsk).",
|
||||
"webhooksInfo": "Habitica tilbyder webhooks, så information kan blive sendt til et script på en anden hjemmeside, når der sker bestemte ting med din konto. Du kan specificere disse scripts her. Vær forsigtig med denne funktion. En forkert URL kan forårsage fejl eller gøre Habitica langsom. Se wiki-siden <a target=\"_blank\" href=\"https://habitica.fandom.com/wiki/Webhooks\">Webhooks</a> (EN) for mere information.",
|
||||
"enabled": "Aktiveret",
|
||||
"webhookURL": "Webhook URL",
|
||||
"invalidUrl": "ugyldig url",
|
||||
"invalidEnabled": "Parametren \"aktiveret\" skal være en boolesk værdi.",
|
||||
"invalidWebhookId": "Parametren \"id\" skal være et gyldigt Unikt Bruger-ID.",
|
||||
"missingWebhookId": "Webhook-id påkrævet.",
|
||||
"invalidWebhookType": "\"<%= type %>\" er ikke en gyldig værdi for parametren \"type\".",
|
||||
"invalidEnabled": "Parametret \"aktiveret\" skal være en boolesk værdi.",
|
||||
"invalidWebhookId": "Parametret \"id\" skal være et gyldigt unikt bruger-ID.",
|
||||
"missingWebhookId": "Webhook-ID påkrævet.",
|
||||
"invalidWebhookType": "\"<%= type %>\" er ikke en gyldig værdi for parametret \"type\".",
|
||||
"webhookBooleanOption": "\"<%= option %>\" skal være en boolesk værdi.",
|
||||
"webhookIdAlreadyTaken": "En webhook med id'et <%= id %> findes allerede.",
|
||||
"noWebhookWithId": "Der er ingen webhook med id'et <%= id %>.",
|
||||
"regIdRequired": "RegId påkrævet",
|
||||
"invalidPushClient": "Ugyldig klient. Kun officielle Habitica-klienter kan modtage push notifikationer.",
|
||||
"webhookIdAlreadyTaken": "En webhook med ID'et <%= id %> findes allerede.",
|
||||
"noWebhookWithId": "Der er ingen webhook med ID'et <%= id %>.",
|
||||
"regIdRequired": "RegID påkrævet",
|
||||
"invalidPushClient": "Ugyldig klient. Kun officielle Habitica-klienter kan modtage pushnotifikationer.",
|
||||
"pushDeviceAdded": "Push-enhed tilføjet",
|
||||
"pushDeviceAlreadyAdded": "Denne bruger har allerede push-enheden",
|
||||
"pushDeviceNotFound": "Brugeren har ingen push-enhed med dette ID.",
|
||||
"pushDeviceRemoved": "Push-enhed fjernet.",
|
||||
"buyGemsGoldCap": "Maksimum hævet til <%= amount %>",
|
||||
"mysticHourglass": "<%= amount %> Mystiske Timeglas",
|
||||
"mysticHourglassText": "Mystiske Timeglas giver dig adgang til at købe tidligere måneders Mystiske Gendstande-sæt.",
|
||||
"mysticHourglass": "<%= amount %> mystiske timeglas",
|
||||
"mysticHourglassText": "Mystiske timeglas giver dig adgang til at købe mystiske sæt fra tidligere måneder.",
|
||||
"purchasedPlanId": "Løbende $<%= price %> USD hver <%= months %>. måned (<%= plan %>)",
|
||||
"purchasedPlanExtraMonths": "Du har <%= months %> måneders overskydende abonnementkredit.",
|
||||
"consecutiveSubscription": "Fortløbende Abonnement",
|
||||
"consecutiveMonths": "Fortløbende Måneder:",
|
||||
"gemCapExtra": "Ekstra Ædelstensmaksimum:",
|
||||
"mysticHourglasses": "Mystiske Timeglas:",
|
||||
"consecutiveSubscription": "Fortløbende abonnement",
|
||||
"consecutiveMonths": "Fortløbende måneder:",
|
||||
"gemCapExtra": "Ekstra ædelstensmaksimum:",
|
||||
"mysticHourglasses": "Mystiske timeglas:",
|
||||
"mysticHourglassesTooltip": "Mystiske timeglas",
|
||||
"paypal": "PayPal",
|
||||
"amazonPayments": "Amazon Payments",
|
||||
"amazonPaymentsRecurring": "Det er nødvendigt at sætte hak i boksen nedenunder for at oprette dit abonnement. Det vil tillade at din Amazonkonto kan bruges til gentagne betalinger for <strong>dette</strong> abonnement. Det vil ikke få til Amazonkonto til at blive brugt automatisk ved fremtidige køb.",
|
||||
"amazonPaymentsRecurring": "Det er nødvendigt at sætte hak i boksen nedenunder for at oprette dit abonnement. Det vil tillade, at din Amazonkonto kan bruges til gentagne betalinger for <strong>dette</strong> abonnement. Det vil ikke forårsage, at din Amazonkonto bliver brugt automatisk ved fremtidige køb.",
|
||||
"timezone": "Tidszone",
|
||||
"timezoneUTC": "Habitica bruger tidszonen sat på din PC, som er: <strong><%= utc %></strong>",
|
||||
"timezoneInfo": "Hvis tidszonen er forkert, kan du først genindlæse denne side ved hjælp af din browsers knap til genindlæsning for at give Habitica de mest opdaterede informationer. Hvis den stadig er forkert, kan du justere din tidszone på din PC og derefter genindlæse siden igen.<br><br> <strong>Hvis du bruger Habitica på andre PC'er eller mobile enheder, skal tidszonen være den samme på dem alle.</strong> Hvis dine Daglige er blevet nulstellet på det forkerte tidspunkt. kan du gentage denne gennemgang på alle andre PC'er og i en browser på dine mobile enheder.",
|
||||
"timezoneUTC": "Habitica bruger tidszonen fra din PC, som er: <strong><%= utc %></strong>",
|
||||
"timezoneInfo": "Hvis tidszonen er forkert, kan du først genindlæse denne side, ved hjælp af din browsers knap til genindlæsning, for at give Habitica de mest opdaterede informationer. Hvis den stadig er forkert, kan du justere din tidszone på din PC, og derefter genindlæse siden igen.<br><br> <strong>Hvis du bruger Habitica på andre PC'er eller mobile enheder, skal tidszonen være den samme på dem alle.</strong> Hvis dine Daglige er blevet nulstillet på det forkerte tidspunkt. så gentag venligst denne gennemgang af alle PC'er og i en browser på alle mobile enheder.",
|
||||
"push": "Push",
|
||||
"about": "Om Habitica",
|
||||
"setUsernameNotificationTitle": "Bekræft dit brugernavn!",
|
||||
"setUsernameNotificationBody": "Vi vil skifte loginnavne til unikke, offentlige brugernavne snart. Dette brugernavn vil blive brugt til invitationer, @tags i chat og beskeder.",
|
||||
"setUsernameNotificationBody": "Vi ændrer snart loginnavne til unikke, offentlige brugernavne. Dette brugernavn vil blive brugt til invitationer, @tags i chat og beskeder.",
|
||||
"usernameIssueSlur": "Brugernavne må ikke indeholde upassende sprogbrug.",
|
||||
"usernameIssueForbidden": "Brugernavne må ikke indeholde blokerede ord.",
|
||||
"usernameIssueLength": "Brugernavne skal være mellem 1 og 20 tegn.",
|
||||
@@ -200,9 +200,13 @@
|
||||
"currentUsername": "Nuværende brugernavn:",
|
||||
"displaynameIssueLength": "Displaynavne skal indeholde mellem 1 og 30 tegn.",
|
||||
"displaynameIssueSlur": "Displaynavne må ikke indeholde upassende sprogbrug.",
|
||||
"goToSettings": "Gå til Indstillinger",
|
||||
"goToSettings": "Gå til indstillinger",
|
||||
"usernameVerifiedConfirmation": "Dit brugernavn, <%= username %>, er blevet bekræftet!",
|
||||
"usernameNotVerified": "Bekræft venligst dit brugernavn.",
|
||||
"changeUsernameDisclaimer": "Vi vil lave loginnavne om til unikke, offentlige brugernavne snart. Dette brugernavn vil blive brugt til invitationer, @tags i chat og beskeder.",
|
||||
"verifyUsernameVeteranPet": "Et af disse Veterankæledyr venter på dig når du er færdig med at bekræfte!"
|
||||
"changeUsernameDisclaimer": "Dette brugernavn vil blive brugt til invitationer, @tags i chat og beskeder.",
|
||||
"verifyUsernameVeteranPet": "Et af disse veterankæledyr venter på dig, når du er færdig med at bekræfte!",
|
||||
"onlyPrivateSpaces": "Kun i private rum",
|
||||
"everywhere": "Alle steder",
|
||||
"suggestMyUsername": "Foreslå mit brugernavn",
|
||||
"mentioning": "Tagging"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"achievement": "Erfolg",
|
||||
"share": "Teilen",
|
||||
"onwards": "Auf geht's!",
|
||||
"levelup": "Durch das Erfüllen Deiner Ziele im Leben bist Du ein Level aufgestiegen und wurdest vollständig geheilt!",
|
||||
"levelup": "Durch das Erfüllen Deiner Ziele im realen Leben bist Du ein Level aufgestiegen und wurdest vollständig geheilt!",
|
||||
"reachedLevel": "Du hast Level <%= level %> erreicht",
|
||||
"achievementLostMasterclasser": "Quest-Erfüller: Klassenmeister-Reihe",
|
||||
"achievementLostMasterclasserText": "Hat alle sechzehn Quests in der Klassenmeister-Questreihe abgeschlossen und das Rätsel des Verschwundenen Klassenmeisters gelöst!",
|
||||
@@ -40,5 +40,28 @@
|
||||
"achievementPrimedForPaintingText": "Hat alle weißen Haustiere gesammelt.",
|
||||
"achievementPearlyProText": "Hat alle weißen Reittiere gezähmt.",
|
||||
"achievementPearlyPro": "Perlweiß-Profi",
|
||||
"achievementPrimedForPainting": "Grundierung gemalt"
|
||||
"achievementPrimedForPainting": "Grundierung gemalt",
|
||||
"achievementCreatedTask": "Erstelle eine Aufgabe",
|
||||
"onboardingCompleteDesc": "Du hast durch das Abschließen der Liste <strong>5 Erfolge</strong> freigeschaltet und <strong class=\"gold-amount\">100</strong> Goldstücke verdient.",
|
||||
"earnedAchievement": "Du hast einen Erfolg freigeschaltet!",
|
||||
"achievementPurchasedEquipmentModalText": "Durch Ausrüstung kann Dein Avatar personalisiert und die Attributswerte erhöht werden",
|
||||
"achievementPurchasedEquipment": "Erwerbe Ausrüstung",
|
||||
"achievementFedPetModalText": "Es gibt viele verschiedene Arten an Futter, aber Haustiere können wählerisch sein",
|
||||
"achievementFedPet": "Füttere ein Haustier",
|
||||
"achievementHatchedPet": "Schlüpfe ein Haustier",
|
||||
"hideAchievements": "<%= category %> ausblenden",
|
||||
"showAllAchievements": "Alle <%= category %> anzeigen",
|
||||
"viewAchievements": "Erfolge ansehen",
|
||||
"letsGetStarted": "Los geht's!",
|
||||
"onboardingProgress": "<%= percentage %>% Fortschritt",
|
||||
"gettingStartedDesc": "Erstelle eine Aufgabe, schließe sie ab und prüfe dann Deine Belohnungen. Du schaltest <strong>5 Erfolge</strong> frei und erhältst <strong class=\"gold-amount\">100 Goldstücke</strong>, sobald Du fertig bist!",
|
||||
"achievementCreatedTaskModalText": "Füge eine Aufgabe hinzu, für etwas, das Du diese Woche erreichen willst",
|
||||
"achievementPurchasedEquipmentText": "Hat den ersten Rüstungsgegenstand gekauft.",
|
||||
"achievementFedPetText": "Hat das erste Haustier gefüttert.",
|
||||
"achievementHatchedPetModalText": "Schau in Dein Inventar und versuche ein Schlüpfelixier mit einem Ei zu kombinieren",
|
||||
"achievementHatchedPetText": "Hat das erste Haustier geschlüpft.",
|
||||
"achievementCompletedTaskModalText": "Hake Deine Aufgaben ab, um Belohnungen zu verdienen",
|
||||
"achievementCompletedTaskText": "Hat die erste Aufgabe erledigt.",
|
||||
"achievementCompletedTask": "Erledige eine Aufgabe",
|
||||
"achievementCreatedTaskText": "Hat die erste Aufgabe erstellt."
|
||||
}
|
||||
|
||||
@@ -485,5 +485,12 @@
|
||||
"backgroundHolidayWreathText": "Adventskranz",
|
||||
"backgroundHolidayMarketNotes": "Finde die perfekten Geschenke und Dekorationsartikel auf einem Weihnachtsmarkt.",
|
||||
"backgroundHolidayMarketText": "Weihnachtsmarkt",
|
||||
"backgrounds122019": "SET 67: Veröffentlicht im Dezember 2019"
|
||||
"backgrounds122019": "Set 67: Veröffentlicht im Dezember 2019",
|
||||
"backgroundSnowglobeNotes": "Schüttele eine Schneekugel und nimm Deinen Platz im Mikrokosmos einer Winterlandschaft ein.",
|
||||
"backgroundSnowglobeText": "Schneekugel",
|
||||
"backgroundDesertWithSnowNotes": "Erlebe die rare und stille Schönheit einer Verschneiten Wüste.",
|
||||
"backgroundDesertWithSnowText": "Verschneite Wüste",
|
||||
"backgroundBirthdayPartyNotes": "Feiere den Geburtstag Deines Lieblingsmitmenschen in Habitica.",
|
||||
"backgroundBirthdayPartyText": "Geburtstagsfeier",
|
||||
"backgrounds012020": "Set 68: Veröffentlicht im Januar 2020"
|
||||
}
|
||||
|
||||
@@ -352,5 +352,6 @@
|
||||
"questEggRobotText": "Roboter-Haustier",
|
||||
"hatchingPotionShadow": "Schatten",
|
||||
"premiumPotionUnlimitedNotes": "Nicht auf Eier von Quest-Haustieren anwendbar.",
|
||||
"hatchingPotionAmber": "Bernstein"
|
||||
"hatchingPotionAmber": "Bernstein",
|
||||
"hatchingPotionAurora": "Polarlicht"
|
||||
}
|
||||
|
||||
@@ -331,5 +331,6 @@
|
||||
"getStarted": "Auf gehts!",
|
||||
"mobileApps": "Mobile Apps",
|
||||
"learnMore": "Mehr erfahren",
|
||||
"communityInstagram": "Instagram"
|
||||
"communityInstagram": "Instagram",
|
||||
"minPasswordLength": "Das Passwort muss mindestens 8 Zeichen haben."
|
||||
}
|
||||
|
||||
@@ -1978,10 +1978,52 @@
|
||||
"headMystery201911Notes": "Jede Kristallspitze an diesem Hut verleiht Dir eine besondere Kraft: mystisches Hellsehen, arkane Weisheit und... hexerisches Tellerdrehen? Na dann... Gewährt keinen Attributbonus. Abonnentengegenstand, November 2019.",
|
||||
"backMystery201912Notes": "Gleite leise über glänzende Schneefelder und schimmernde Berge mit diesen eisigen Flügeln. Gewährt keinen Attributbonus. Abonnentengegenstand, Dezember 2019.",
|
||||
"backMystery201912Text": "Frostige Feenflügel",
|
||||
"headMystery201912Notes": "Diese glitzernde Schneeflocke verleiht Dir Resistenz gegen die beißende Kälte, unabhängig davon wie hoch Du fliegst! Gewährt keinen Attributbonus. Abonnentengegenstand, Dezember 2019.",
|
||||
"headMystery201912Text": "Frostige Feenkrone",
|
||||
"headMystery201912Notes": "Diese glitzernde Schneeflocke verleiht Dir Widerstandfähigkeit gegen die beißende Kälte, unabhängig davon wie hoch Du fliegst! Gewährt keinen Attributbonus. Abonnentengegenstand, Dezember 2019.",
|
||||
"headMystery201912Text": "Polar-Pixiekrone",
|
||||
"headArmoireEarflapHatText": "Uschanka",
|
||||
"armorArmoireDuffleCoatNotes": "Reise stilsicher durch eisige Gefilde mit diesem lauschigen Wollmantel. Erhöht Ausdauer und Wahrnehmung um jeweils <%= attrs %>. Verzauberter Schrank: Düffelmantel-Set (Gegenstand 1 von 2).",
|
||||
"armorArmoireDuffleCoatText": "Düffelmantel",
|
||||
"headArmoireEarflapHatNotes": "Wenn Du etwas suchst, um Deinen Kopf wohlig warm zu halten, ist dieser Hut genau Dein Ding! Erhöht Intelligenz und Stärke um je <%= attrs %>. Verzauberter Schrank: Düffelmantel-Set (Gegenstand 2 von 2)."
|
||||
"headArmoireEarflapHatNotes": "Wenn Du etwas suchst, um Deinen Kopf wohlig warm zu halten, ist dieser Hut genau Dein Ding! Erhöht Intelligenz und Stärke um je <%= attrs %>. Verzauberter Schrank: Düffelmantel-Set (Gegenstand 2 von 2).",
|
||||
"shieldSpecialWinter2020HealerText": "Gigantische Zimtstange",
|
||||
"shieldSpecialWinter2020WarriorText": "Zirkulärer Tannenzapfen",
|
||||
"headSpecialWinter2020HealerText": "Sternanisabzeichen",
|
||||
"headSpecialWinter2020MageText": "Glockenkrone",
|
||||
"armorSpecialWinter2020HealerText": "Orangenschalenumhang",
|
||||
"armorSpecialWinter2020WarriorNotes": "Oh mächtige Kiefer, oh überragende Tanne, leiht mir eure Kraft. Oder besser gesagt, eure Ausdauer! Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"armorSpecialWinter2020WarriorText": "Borkenrüstung",
|
||||
"armorSpecialWinter2020RogueText": "Puscheliger Parka",
|
||||
"weaponSpecialWinter2020HealerNotes": "Schwinge es und sein Aroma wird Deine Freunde und Helfer herbeirufen, um mit dem Kochen und Backen zu beginnen! Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"weaponSpecialWinter2020HealerText": "Gewürznelkenzepter",
|
||||
"weaponSpecialWinter2020MageNotes": "Mit etwas Übung, kannst Du diese akustische Magie in jeder gewünschten Frequenz generieren: ein meditatives Summen, ein festliches Läuten oder ein ROTE AUFGABE ÜBERFÄLLIG-ALARM. Erhöht Intelligenz um <%= int %> und Wahrnehmung um <%= per %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"weaponSpecialWinter2020MageText": "Ringelige Schallwellen",
|
||||
"weaponSpecialWinter2020WarriorNotes": "Zurück, Eichhörnchen! Ihr bekommt kein einziges Stück davon! ...Aber wenn Ihr alle bei einer Tasse Kakao abhängen wollt, geht das klar. Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"weaponSpecialWinter2020WarriorText": "Zackiger Tannenzapfen",
|
||||
"weaponSpecialWinter2020RogueNotes": "Dunkelheit ist des Schurken Element. Wer eigne sich da besser, den Weg zu leuchten in der dunkelsten Jahreszeit? Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"weaponSpecialWinter2020RogueText": "Laternenstab",
|
||||
"shieldSpecialWinter2020HealerNotes": "Hast Du das Gefühl, Du seist zu gut für diese Welt, zu unverfälscht? Nur diese Schönheit unter den Gewürzen ist Deiner würdig. Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"headSpecialWinter2020HealerNotes": "Bitte nimm es ab, bevor Du versuchst damit einen Chai oder Kaffee aufzubrühen. Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"headSpecialWinter2020MageNotes": "Oh! Süßer die Glocken nie klingen / als zu der Weihnachtszeit, / ’s ist, als ob Engelein singen, / \"Wende 'Flammenstoß' an\". Erhöht Wahrnehmung um <%= per %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"headSpecialWinter2020WarriorNotes": "Ein stechendes Gefühl auf Deinem Kopf ist ein kleiner Preis für saisonale Pracht. Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2019-202 Winterausrüstung.",
|
||||
"headSpecialWinter2020WarriorText": "Schneegekrönter Kopfschmuck",
|
||||
"headSpecialWinter2020RogueNotes": "Ein Schurke geht in dieser Mütze die Straße entlang, die Leute wissen, so jemand fürchtet nichts. Erhöht Wahrnehmung um <%= per %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"headSpecialWinter2020RogueText": "Flauschige Bommelmütze",
|
||||
"armorSpecialWinter2020HealerNotes": "Ein opulenter Umhang für jene, mit festlichem Geschmack! Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"armorSpecialWinter2020MageText": "Rundliche Robe",
|
||||
"armorSpecialWinter2020RogueNotes": "Es besteht kein Zweifel, dass Du Stürmen mit der inneren Wärme Deines Elans und Deiner Hingabe trotzen kannst, aber es kann nicht schaden, sich dem Wetter entsprechend anzuziehen. Erhöht Wahrnehmung um <%= per %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"backMystery202001Notes": "Diese fluffigen Schwänze tragen himmlische Kräfte in sich, und ein ebenso hohes Niveau an Niedlichkeit! Gewährt keinen Attributbonus. Abonnentengegenstand, Januar 2020.",
|
||||
"backMystery202001Text": "Fünf fabelhafte Schwänze",
|
||||
"shieldSpecialWinter2020WarriorNotes": "Benutze ihn als Schild bis die Samen herunterfallen, und dann kannst Du ihn auf einen Kranz binden! Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"headMystery202001Notes": "Du bekommt ein so scharfes Gehör, dass Du hören kannst, wie die Sterne funkeln und der Mond sich dreht. Gewährt keinen Attributbonus. Abonnentengegenstand, Januar 2020.",
|
||||
"headMystery202001Text": "Fabelhafte Fuchsohren",
|
||||
"headSpecialNye2019Notes": "Du hast einen Frevelhaften Fetenhut erhalten! Trage ihn mit Stolz während Du das neue Jahr einläutest! Gewährt keinen Attributbonus.",
|
||||
"headSpecialNye2019Text": "Frevelhafter Fetenhut",
|
||||
"armorSpecialWinter2020MageNotes": "Läute das neue Jahr in dieser warmen, gemütlichen Robe ein, die Dich gegen übermässige Erschütterungen puffert. Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
|
||||
"shieldArmoireBirthdayBannerNotes": "Feiere Deinen besonderen Tag, den besonderen Tag von jemandem, den Du liebst, oder trage es zum Geburtstag von Habitica am 31. Januar! Erhöht Stärke um <%= str %>. Verzauberter Schrank: Herzlichen Glückwunsch zum Geburtstag-Set (Gegenstand 4 von 4).",
|
||||
"shieldArmoireBirthdayBannerText": "Geburtstagsgösch",
|
||||
"headArmoireFrostedHelmNotes": "Der perfekte Kopfschutz für jede Feier! Erhöht Intelligenz um <%= int %>. Verzauberter Schrank: Herzlichen Glückwunsch zum Geburtstag-Set (Gegenstand 1 von 4).",
|
||||
"headArmoireFrostedHelmText": "Glasierter Helm",
|
||||
"armorArmoireLayerCakeArmorNotes": "Sie ist schützend und lecker! Erhöht Ausdauer um <%= con %>. Verzauberter Schrank: Herzlichen Glückwunsch zum Geburtstag-Set (Gegenstand 2 von 4).",
|
||||
"armorArmoireLayerCakeArmorText": "Schichttortenrüstung",
|
||||
"weaponArmoireHappyBannerNotes": "Steht das \"H\" für Herzlich oder für Habitica? Entscheide selbst! Erhöht Wahrnehmung um <%= per %>. Verzauberter Schrank: Herzlichen Glückwunsch zum Geburtstag-Set (Gegenstand 3 von 4).",
|
||||
"weaponArmoireHappyBannerText": "Herzliche Gösch"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"stringNotFound": "String '<%= string %>' nicht gefunden.",
|
||||
"titleIndex": "Habitica | Dein Leben, das Rollenspiel",
|
||||
"habitica": "Habitica",
|
||||
"habiticaLink": "<a href='http://habitica.fandom.com/wiki/Habitica' target='_blank'>Habitica</a>",
|
||||
"habiticaLink": "<a href='http://habitica.fandom.com/de/wiki/Habitica' target='_blank'>Habitica</a>",
|
||||
"onward": "Vorwärts!",
|
||||
"done": "Erledigt",
|
||||
"gotIt": "Verstanden!",
|
||||
@@ -294,5 +294,7 @@
|
||||
"options": "Optionen",
|
||||
"loadEarlierMessages": "Lade ältere Nachrichten",
|
||||
"demo": "Demo",
|
||||
"finish": "Abschliessen"
|
||||
"finish": "Abschliessen",
|
||||
"congratulations": "Gratulation!",
|
||||
"onboardingAchievs": "Einstiegserfolge"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user