mirror of
https://github.com/HabitRPG/habitica.git
synced 2026-05-20 19:48:38 -05:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a09c6b84d5 | |||
| 3ba161abbe | |||
| 9038572ff9 | |||
| 80825f3478 | |||
| a254c50a8e | |||
| 918a769441 | |||
| 1fab19acf4 | |||
| 5743fb86b0 | |||
| 5443bf2459 | |||
| c0d5566417 | |||
| ded71b46c5 | |||
| 9693ad321c | |||
| dd3679f329 | |||
| f3029953dc | |||
| 01881b2fd8 | |||
| 11a22d0f5d | |||
| 5f9bf07045 | |||
| 719c03e2f5 | |||
| 379afa9554 | |||
| dbc23e89b8 | |||
| 0c6e254742 | |||
| 8327e69bdd | |||
| 2d953f4f59 | |||
| 7118d63949 | |||
| 20af8d038e | |||
| 3d9dfbb5e1 | |||
| ae0b966f45 | |||
| cef8a34c06 | |||
| 6432823eec | |||
| 563b780d85 | |||
| aa9b1b2cac | |||
| 401e541b86 | |||
| c13bed3bad | |||
| b3c4817fb4 | |||
| 7c9c45ac5f | |||
| 95142e3684 | |||
| dc1cce6ddb | |||
| 43cf77f33c | |||
| 93780d7056 |
@@ -1,6 +1,13 @@
|
||||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'phillip/**'
|
||||
- 'sabrecat/**'
|
||||
- 'kalista/**'
|
||||
- 'natalie/**'
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
+1
-1
@@ -47,5 +47,5 @@ webpack.webstorm.config
|
||||
|
||||
# mongodb replica set for local dev
|
||||
mongodb-*.tgz
|
||||
/mongodb-data
|
||||
/mongodb-data*
|
||||
/.nyc_output
|
||||
|
||||
@@ -64,6 +64,15 @@ function filterFile (file) {
|
||||
if (file.relative.indexOf('icon_background') === 0) {
|
||||
return false;
|
||||
}
|
||||
if (file.relative.indexOf('notif_') === 0) {
|
||||
return false;
|
||||
}
|
||||
if (file.relative.indexOf('quest_') === 0) {
|
||||
return false;
|
||||
}
|
||||
if (file.relative.indexOf('inventory_quest_') === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
Submodule habitica-images updated: 1db5adcca4...aa72332019
Generated
+203
-128
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "habitica",
|
||||
"version": "5.33.3",
|
||||
"version": "5.35.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "habitica",
|
||||
"version": "5.33.3",
|
||||
"version": "5.35.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.22.10",
|
||||
@@ -56,7 +56,7 @@
|
||||
"method-override": "^3.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"moment-recur": "^1.0.7",
|
||||
"mongoose": "^7.8.3",
|
||||
"mongoose": "^8.9.5",
|
||||
"morgan": "^1.10.0",
|
||||
"nconf": "^0.12.1",
|
||||
"node-gcm": "^1.0.5",
|
||||
@@ -3047,7 +3047,7 @@
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
|
||||
"integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==",
|
||||
"optional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sparse-bitfield": "^3.0.3"
|
||||
}
|
||||
@@ -3677,14 +3677,15 @@
|
||||
"node_modules/@types/webidl-conversions": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
|
||||
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/whatwg-url": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz",
|
||||
"integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==",
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
|
||||
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/webidl-conversions": "*"
|
||||
}
|
||||
},
|
||||
@@ -6401,10 +6402,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bson": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bson/-/bson-6.3.0.tgz",
|
||||
"integrity": "sha512-balJfqwwTBddxfnidJZagCBPP/f48zj9Sdp3OJswREOgsJzHiQSaOIAtApSgDQFYgHqAvFkp53AFSqjMDZoTFw==",
|
||||
"dev": true,
|
||||
"version": "6.10.2",
|
||||
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.2.tgz",
|
||||
"integrity": "sha512-5afhLTjqDSA3akH56E+/2J6kTDuSIlBxyXPdQslj9hcIgOUE378xdOfZvC/9q3LifJNI6KR/juZ+d0NRNYBwXg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=16.20.1"
|
||||
}
|
||||
@@ -13360,28 +13361,6 @@
|
||||
"resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
|
||||
"integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
|
||||
"integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
|
||||
"dependencies": {
|
||||
"jsbn": "1.1.0",
|
||||
"sprintf-js": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address/node_modules/jsbn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
|
||||
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="
|
||||
},
|
||||
"node_modules/ip-address/node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -14296,9 +14275,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/kareem": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz",
|
||||
"integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==",
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
|
||||
"integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
@@ -14307,7 +14287,7 @@
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/kerberos/-/kerberos-1.1.7.tgz",
|
||||
"integrity": "sha512-1zXg4rARjsh/VMz2jjZeTfRHbJTVNR6f2DYHbLvtUSOW1satj33Fvc7vOJ0YVWB9+/9ITJWd1QKp4w217SsiFA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
@@ -14965,8 +14945,7 @@
|
||||
"node_modules/memory-pager": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
|
||||
"optional": true
|
||||
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
|
||||
},
|
||||
"node_modules/meow": {
|
||||
"version": "3.7.0",
|
||||
@@ -15475,43 +15454,47 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb-connection-string-url": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz",
|
||||
"integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==",
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
|
||||
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/whatwg-url": "^8.2.1",
|
||||
"whatwg-url": "^11.0.0"
|
||||
"@types/whatwg-url": "^11.0.2",
|
||||
"whatwg-url": "^14.1.0 || ^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
|
||||
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz",
|
||||
"integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.1"
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
|
||||
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz",
|
||||
"integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^3.0.0",
|
||||
"tr46": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb-core": {
|
||||
@@ -15596,55 +15579,64 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.3.tgz",
|
||||
"integrity": "sha512-eFnbkKgyVrICoHB6tVJ4uLanS7d5AIo/xHkEbQeOv6g2sD7gh/1biRwvFifsmbtkIddQVNr3ROqHik6gkknN3g==",
|
||||
"version": "8.9.7",
|
||||
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.9.7.tgz",
|
||||
"integrity": "sha512-mvNXmU0V8qZzMR/qoK2mjT4Ti2ALdtfS0teK+twxhlGkwzOD76V02/zWajTu2MJ7QyEmZe9OWvnJsIY0iAuX3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bson": "^5.5.0",
|
||||
"kareem": "2.5.1",
|
||||
"mongodb": "5.9.2",
|
||||
"bson": "^6.10.1",
|
||||
"kareem": "2.6.3",
|
||||
"mongodb": "~6.12.0",
|
||||
"mpath": "0.9.0",
|
||||
"mquery": "5.0.0",
|
||||
"ms": "2.1.3",
|
||||
"sift": "16.0.1"
|
||||
"sift": "17.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.20.1"
|
||||
"node": ">=16.20.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mongoose"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/bson": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz",
|
||||
"integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==",
|
||||
"node_modules/mongoose/node_modules/kerberos": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/kerberos/-/kerberos-2.2.1.tgz",
|
||||
"integrity": "sha512-Vlyv1tjAPb0y2VIJ03dKkUjsneGIBuTkH24uGRx6/DrKpFlVuGPmct3m5aEotljVUlw7PAGWABwR5aNeW7y8Zw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^6.1.0",
|
||||
"prebuild-install": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.20.1"
|
||||
"node": ">=12.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/mongodb": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
|
||||
"integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==",
|
||||
"version": "6.12.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.12.0.tgz",
|
||||
"integrity": "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bson": "^5.5.0",
|
||||
"mongodb-connection-string-url": "^2.6.0",
|
||||
"socks": "^2.7.1"
|
||||
"@mongodb-js/saslprep": "^1.1.9",
|
||||
"bson": "^6.10.1",
|
||||
"mongodb-connection-string-url": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.20.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@mongodb-js/saslprep": "^1.1.0"
|
||||
"node": ">=16.20.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/credential-providers": "^3.188.0",
|
||||
"@mongodb-js/zstd": "^1.0.0",
|
||||
"kerberos": "^1.0.0 || ^2.0.0",
|
||||
"mongodb-client-encryption": ">=2.3.0 <3",
|
||||
"snappy": "^7.2.2"
|
||||
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
|
||||
"gcp-metadata": "^5.2.0",
|
||||
"kerberos": "^2.0.1",
|
||||
"mongodb-client-encryption": ">=6.0.0 <7",
|
||||
"snappy": "^7.2.2",
|
||||
"socks": "^2.7.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@aws-sdk/credential-providers": {
|
||||
@@ -15653,6 +15645,9 @@
|
||||
"@mongodb-js/zstd": {
|
||||
"optional": true
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"optional": true
|
||||
},
|
||||
"kerberos": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -15661,6 +15656,9 @@
|
||||
},
|
||||
"snappy": {
|
||||
"optional": true
|
||||
},
|
||||
"socks": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -15669,6 +15667,105 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/mongoose/node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/mongoose/node_modules/node-abi": {
|
||||
"version": "3.74.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz",
|
||||
"integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/node-addon-api": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||
"integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/mongoose/node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monk": {
|
||||
"version": "7.3.4",
|
||||
"resolved": "https://registry.npmjs.org/monk/-/monk-7.3.4.tgz",
|
||||
@@ -16082,7 +16179,7 @@
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.1.tgz",
|
||||
"integrity": "sha512-/2D0wOQPgaUWzVSVgRMx+trKJRC2UG4SUc4oCJoXx9Uxjtp0Vy3/kt7zcbxHF8+Z/pK3UloLWzBISg72brfy1w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"semver": "^5.4.1"
|
||||
}
|
||||
@@ -16091,7 +16188,7 @@
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
@@ -16281,7 +16378,7 @@
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
|
||||
"integrity": "sha512-6kM8CLXvuW5crTxsAtva2YLrRrDaiTIkIePWs9moLHqbFWT94WpNFjwS/5dfLfECg5i/lkmw3aoqVidxt23TEQ==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "1.0.10",
|
||||
@@ -17896,7 +17993,7 @@
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.2.tgz",
|
||||
"integrity": "sha512-PzYWIKZeP+967WuKYXlTOhYBgGOvTRSfaKI89XnfJ0ansRAH7hDU45X+K+FZeI1Wb/7p/NnuctPH3g0IqKUuSQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^1.0.3",
|
||||
"expand-template": "^2.0.3",
|
||||
@@ -17924,7 +18021,7 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
|
||||
"integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -17933,13 +18030,13 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
|
||||
"integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/are-we-there-yet": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
|
||||
"integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"delegates": "^1.0.0",
|
||||
"readable-stream": "^2.0.6"
|
||||
@@ -17949,7 +18046,7 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"detect-libc": "bin/detect-libc.js"
|
||||
},
|
||||
@@ -17961,7 +18058,7 @@
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
|
||||
"integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"aproba": "^1.0.3",
|
||||
"console-control-strings": "^1.0.0",
|
||||
@@ -17977,7 +18074,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
|
||||
"integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
},
|
||||
@@ -17989,13 +18086,13 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/npmlog": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
|
||||
"integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"are-we-there-yet": "~1.1.2",
|
||||
"console-control-strings": "~1.1.0",
|
||||
@@ -18007,7 +18104,7 @@
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -18022,7 +18119,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -18031,7 +18128,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
|
||||
"integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
@@ -18045,7 +18142,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
|
||||
"integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
},
|
||||
@@ -19576,9 +19673,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sift": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz",
|
||||
"integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ=="
|
||||
"version": "17.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
|
||||
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
@@ -19608,7 +19706,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
|
||||
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^4.2.0",
|
||||
"once": "^1.3.1",
|
||||
@@ -19619,7 +19717,7 @@
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
|
||||
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^2.0.0"
|
||||
},
|
||||
@@ -19631,7 +19729,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
|
||||
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
@@ -19772,15 +19870,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/snapdragon": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
|
||||
@@ -19900,19 +19989,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
|
||||
"integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
|
||||
"dependencies": {
|
||||
"ip-address": "^9.0.5",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-keys": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz",
|
||||
@@ -19990,7 +20066,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"memory-pager": "^1.0.2"
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "habitica",
|
||||
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
|
||||
"version": "5.33.3",
|
||||
"version": "5.35.1",
|
||||
"main": "./website/server/index.js",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.22.10",
|
||||
@@ -51,7 +51,7 @@
|
||||
"method-override": "^3.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"moment-recur": "^1.0.7",
|
||||
"mongoose": "^7.8.3",
|
||||
"mongoose": "^8.9.5",
|
||||
"morgan": "^1.10.0",
|
||||
"nconf": "^0.12.1",
|
||||
"node-gcm": "^1.0.5",
|
||||
@@ -110,6 +110,7 @@
|
||||
"start:simple": "node ./website/server/index.js",
|
||||
"debug": "gulp nodemon --inspect",
|
||||
"mongo:dev": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data --number 1 --quiet",
|
||||
"mongo:test": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data-testing --number 1 --quiet",
|
||||
"postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install",
|
||||
"apidoc": "gulp apidoc",
|
||||
"heroku-postbuild": ".heroku/report_deploy.sh"
|
||||
|
||||
+253
-122
@@ -2,13 +2,22 @@
|
||||
import moment from 'moment';
|
||||
import nconf from 'nconf';
|
||||
import requireAgain from 'require-again';
|
||||
import { recoverCron, cron } from '../../../../website/server/libs/cron';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
generateRes,
|
||||
generateReq,
|
||||
generateTodo,
|
||||
generateDaily,
|
||||
} from '../../../helpers/api-unit.helper';
|
||||
import { cron, cronWrapper } from '../../../../website/server/libs/cron';
|
||||
import { model as User } from '../../../../website/server/models/user';
|
||||
import * as Tasks from '../../../../website/server/models/task';
|
||||
import common from '../../../../website/common';
|
||||
import * as analytics from '../../../../website/server/libs/analyticsService';
|
||||
import { model as Group } from '../../../../website/server/models/group';
|
||||
|
||||
// const scoreTask = common.ops.scoreTask;
|
||||
const CRON_TIMEOUT_WAIT = new Date(5 * 60 * 1000).getTime();
|
||||
const CRON_TIMEOUT_UNIT = new Date(60 * 1000).getTime();
|
||||
|
||||
const pathToCronLib = '../../../../website/server/libs/cron';
|
||||
|
||||
@@ -1200,7 +1209,7 @@ describe('cron', async () => {
|
||||
it('increments perfect day achievement if all (at least 1) due dailies were completed', async () => {
|
||||
daysMissed = 1;
|
||||
tasksByType.dailys[0].completed = true;
|
||||
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
|
||||
tasksByType.dailys[0].isDue = true;
|
||||
|
||||
await cron({
|
||||
user, tasksByType, daysMissed, analytics,
|
||||
@@ -1212,7 +1221,7 @@ describe('cron', async () => {
|
||||
it('does not increment perfect day achievement if no due dailies', async () => {
|
||||
daysMissed = 1;
|
||||
tasksByType.dailys[0].completed = true;
|
||||
tasksByType.dailys[0].startDate = moment(new Date()).add({ days: 1 });
|
||||
tasksByType.dailys[0].isDue = false;
|
||||
|
||||
await cron({
|
||||
user, tasksByType, daysMissed, analytics,
|
||||
@@ -1224,7 +1233,7 @@ describe('cron', async () => {
|
||||
it('gives perfect day buff if all (at least 1) due dailies were completed', async () => {
|
||||
daysMissed = 1;
|
||||
tasksByType.dailys[0].completed = true;
|
||||
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
|
||||
tasksByType.dailys[0].isDue = true;
|
||||
|
||||
const previousBuffs = user.stats.buffs.toObject();
|
||||
|
||||
@@ -1242,7 +1251,7 @@ describe('cron', async () => {
|
||||
user.preferences.sleep = true;
|
||||
daysMissed = 1;
|
||||
tasksByType.dailys[0].completed = true;
|
||||
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
|
||||
tasksByType.dailys[0].isDue = true;
|
||||
|
||||
const previousBuffs = user.stats.buffs.toObject();
|
||||
|
||||
@@ -1259,7 +1268,7 @@ describe('cron', async () => {
|
||||
it('clears buffs if user does not have a perfect day (no due dailys)', async () => {
|
||||
daysMissed = 1;
|
||||
tasksByType.dailys[0].completed = true;
|
||||
tasksByType.dailys[0].startDate = moment(new Date()).add({ days: 1 });
|
||||
tasksByType.dailys[0].isDue = false;
|
||||
|
||||
user.stats.buffs = {
|
||||
str: 1,
|
||||
@@ -1488,78 +1497,6 @@ describe('cron', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifications', async () => {
|
||||
it('adds a user notification', async () => {
|
||||
const mpBefore = user.stats.mp;
|
||||
tasksByType.dailys[0].completed = true;
|
||||
|
||||
const statsComputedRes = common.statsComputed(user);
|
||||
const stubbedStatsComputed = sinon.stub(common, 'statsComputed');
|
||||
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
|
||||
|
||||
daysMissed = 1;
|
||||
const hpBefore = user.stats.hp;
|
||||
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
|
||||
|
||||
await cron({
|
||||
user, tasksByType, daysMissed, analytics,
|
||||
});
|
||||
|
||||
expect(user.notifications.length).to.be.greaterThan(0);
|
||||
expect(user.notifications[1].type).to.equal('CRON');
|
||||
expect(user.notifications[1].data).to.eql({
|
||||
hp: user.stats.hp - hpBefore,
|
||||
mp: user.stats.mp - mpBefore,
|
||||
});
|
||||
|
||||
common.statsComputed.restore();
|
||||
});
|
||||
|
||||
it('condenses multiple notifications into one', async () => {
|
||||
const mpBefore1 = user.stats.mp;
|
||||
tasksByType.dailys[0].completed = true;
|
||||
|
||||
const statsComputedRes = common.statsComputed(user);
|
||||
const stubbedStatsComputed = sinon.stub(common, 'statsComputed');
|
||||
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
|
||||
|
||||
daysMissed = 1;
|
||||
const hpBefore1 = user.stats.hp;
|
||||
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
|
||||
|
||||
await cron({
|
||||
user, tasksByType, daysMissed, analytics,
|
||||
});
|
||||
|
||||
expect(user.notifications.length).to.be.greaterThan(0);
|
||||
expect(user.notifications[1].type).to.equal('CRON');
|
||||
expect(user.notifications[1].data).to.eql({
|
||||
hp: user.stats.hp - hpBefore1,
|
||||
mp: user.stats.mp - mpBefore1,
|
||||
});
|
||||
|
||||
const notifsBefore2 = user.notifications.length;
|
||||
const hpBefore2 = user.stats.hp;
|
||||
const mpBefore2 = user.stats.mp;
|
||||
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
|
||||
await cron({
|
||||
user, tasksByType, daysMissed, analytics,
|
||||
});
|
||||
|
||||
expect(user.notifications.length - notifsBefore2).to.equal(0);
|
||||
expect(user.notifications[0].type).to.not.equal('CRON');
|
||||
expect(user.notifications[1].type).to.equal('CRON');
|
||||
expect(user.notifications[1].data).to.eql({
|
||||
hp: user.stats.hp - hpBefore2 - (hpBefore2 - hpBefore1),
|
||||
mp: user.stats.mp - mpBefore2 - (mpBefore2 - mpBefore1),
|
||||
});
|
||||
expect(user.notifications[0].type).to.not.equal('CRON');
|
||||
common.statsComputed.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('private messages', async () => {
|
||||
let lastMessageId;
|
||||
|
||||
@@ -1606,7 +1543,7 @@ describe('cron', async () => {
|
||||
await cron({
|
||||
user, tasksByType, daysMissed, analytics,
|
||||
});
|
||||
expect(user.notifications.length).to.be.greaterThan(1);
|
||||
expect(user.notifications.length).to.eql(1);
|
||||
expect(user.notifications[0].type).to.eql('LOGIN_INCENTIVE');
|
||||
});
|
||||
|
||||
@@ -1820,64 +1757,258 @@ describe('cron', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('recoverCron', async () => {
|
||||
let locals; let status; let
|
||||
execStub;
|
||||
describe('cron wrapper', () => {
|
||||
let res; let
|
||||
req;
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
execStub = sandbox.stub();
|
||||
sandbox.stub(User, 'findOne').returns({ exec: execStub });
|
||||
|
||||
status = { times: 0 };
|
||||
locals = {
|
||||
user: new User({
|
||||
auth: {
|
||||
local: {
|
||||
username: 'username',
|
||||
lowerCaseUsername: 'username',
|
||||
email: 'email@example.com',
|
||||
salt: 'salt',
|
||||
hashed_password: 'hashed_password', // eslint-disable-line camelcase
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
res = generateRes();
|
||||
req = generateReq();
|
||||
user = await res.locals.user.save();
|
||||
res.analytics = analytics;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('throws an error if user cannot be found', async () => {
|
||||
execStub.returns(Promise.resolve(null));
|
||||
it('calls next when user is not attached', async () => {
|
||||
res.locals.user = null;
|
||||
await cronWrapper(req, res);
|
||||
});
|
||||
|
||||
it('calls next when days have not been missed', async () => {
|
||||
await cronWrapper(req, res);
|
||||
});
|
||||
|
||||
it('should clear todos older than 30 days for free users', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const task = generateTodo(user);
|
||||
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
|
||||
task.completed = true;
|
||||
await task.save();
|
||||
await user.save();
|
||||
|
||||
await cronWrapper(req, res);
|
||||
const taskRes = await Tasks.Task.findOne({ _id: task._id });
|
||||
expect(taskRes).to.not.exist;
|
||||
});
|
||||
|
||||
it('should not clear todos older than 30 days for subscribed users', async () => {
|
||||
user.purchased.plan.customerId = 'subscribedId';
|
||||
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const task = generateTodo(user);
|
||||
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
|
||||
task.completed = true;
|
||||
await Promise.all([task.save(), user.save()]);
|
||||
|
||||
await cronWrapper(req, res);
|
||||
const taskRes = await Tasks.Task.findOne({ _id: task._id });
|
||||
expect(taskRes).to.exist;
|
||||
});
|
||||
|
||||
it('should clear todos older than 90 days for subscribed users', async () => {
|
||||
user.purchased.plan.customerId = 'subscribedId';
|
||||
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
|
||||
const task = generateTodo(user);
|
||||
task.dateCompleted = moment(new Date()).subtract({ days: 91 });
|
||||
task.completed = true;
|
||||
await task.save();
|
||||
await user.save();
|
||||
|
||||
await cronWrapper(req, res);
|
||||
const taskRes = await Tasks.Task.findOne({ _id: task._id });
|
||||
expect(taskRes).to.not.exist;
|
||||
});
|
||||
|
||||
it('should call next if user was not modified after cron', async () => {
|
||||
const hpBefore = user.stats.hp;
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
await user.save();
|
||||
|
||||
await cronWrapper(req, res);
|
||||
expect(hpBefore).to.equal(user.stats.hp);
|
||||
});
|
||||
|
||||
it('runs cron if previous cron was incomplete', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 1 });
|
||||
user.auth.timestamps.loggedin = moment(new Date()).subtract({ days: 4 });
|
||||
const now = new Date();
|
||||
await user.save();
|
||||
|
||||
await cronWrapper(req, res);
|
||||
expect(moment(now).isSame(user.lastCron, 'day'));
|
||||
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
|
||||
});
|
||||
|
||||
it('updates user.auth.timestamps.loggedin and lastCron', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const now = new Date();
|
||||
await user.save();
|
||||
|
||||
await cronWrapper(req, res);
|
||||
expect(moment(now).isSame(user.lastCron, 'day'));
|
||||
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
|
||||
});
|
||||
|
||||
it('does damage for missing dailies', async () => {
|
||||
const hpBefore = user.stats.hp;
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const daily = generateDaily(user);
|
||||
daily.startDate = moment(new Date()).subtract({ days: 2 });
|
||||
await daily.save();
|
||||
await user.save();
|
||||
|
||||
await cronWrapper(req, res);
|
||||
const updatedUser = await User.findOne({ _id: user._id });
|
||||
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
|
||||
});
|
||||
|
||||
it('updates tasks', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const todo = generateTodo(user);
|
||||
const todoValueBefore = todo.value;
|
||||
await Promise.all([todo.save(), user.save()]);
|
||||
|
||||
await cronWrapper(req, res);
|
||||
const todoFound = await Tasks.Task.findOne({ _id: todo._id });
|
||||
expect(todoFound.value).to.be.lessThan(todoValueBefore);
|
||||
});
|
||||
|
||||
it('updates large number of tasks', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const todo = generateTodo(user);
|
||||
const todoValueBefore = todo.value;
|
||||
const start = new Date();
|
||||
const saves = [todo.save(), user.save()];
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const newTodo = generateTodo(user);
|
||||
newTodo.value = i;
|
||||
saves.push(newTodo.save());
|
||||
}
|
||||
await Promise.all(saves);
|
||||
|
||||
await cronWrapper(req, res);
|
||||
const duration = new Date() - start;
|
||||
expect(duration).to.be.lessThan(1000);
|
||||
const todoFound = await Tasks.Task.findOne({ _id: todo._id });
|
||||
expect(moment(start).isSame(user.lastCron, 'day'));
|
||||
expect(moment(start).isSame(user.auth.timestamps.loggedin, 'day'));
|
||||
expect(todoFound.value).to.be.lessThan(todoValueBefore);
|
||||
});
|
||||
|
||||
it('fails entire cron if one task is failing', async () => {
|
||||
const lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
user.lastCron = lastCron;
|
||||
const todo = generateTodo(user);
|
||||
const todoValueBefore = todo.value;
|
||||
const badTodo = generateTodo(user);
|
||||
badTodo.text = 'bad todo';
|
||||
badTodo.attribute = 'bad';
|
||||
await Promise.all([badTodo.save({ validateBeforeSave: false }), todo.save(), user.save()]);
|
||||
|
||||
try {
|
||||
await recoverCron(status, locals);
|
||||
throw new Error('no exception when user cannot be found');
|
||||
await cronWrapper(req, res);
|
||||
} catch (err) {
|
||||
expect(err.message).to.eql(`User ${locals.user._id} not found while recovering.`);
|
||||
expect(err).to.exist;
|
||||
}
|
||||
const todoFound = await Tasks.Task.findOne({ _id: todo._id });
|
||||
expect(moment(lastCron).isSame(user.lastCron, 'day'));
|
||||
expect(todoFound.value).to.be.equal(todoValueBefore);
|
||||
});
|
||||
|
||||
it('applies quest progress', async () => {
|
||||
const hpBefore = user.stats.hp;
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const daily = generateDaily(user);
|
||||
daily.startDate = moment(new Date()).subtract({ days: 2 });
|
||||
await daily.save();
|
||||
|
||||
const questKey = 'dilatory';
|
||||
user.party.quest.key = questKey;
|
||||
|
||||
const party = new Group({
|
||||
type: 'party',
|
||||
name: generateUUID(),
|
||||
leader: user._id,
|
||||
});
|
||||
party.quest.members[user._id] = true;
|
||||
party.quest.key = questKey;
|
||||
await party.save();
|
||||
|
||||
user.party._id = party._id;
|
||||
await user.save();
|
||||
|
||||
party.startQuest(user);
|
||||
|
||||
await cronWrapper(req, res);
|
||||
const updatedUser = await User.findOne({ _id: user._id });
|
||||
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
|
||||
});
|
||||
|
||||
it('cronSignature less than 5 minutes ago should error', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const now = new Date();
|
||||
await User.updateOne({
|
||||
_id: user._id,
|
||||
}, {
|
||||
$set: {
|
||||
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT + CRON_TIMEOUT_UNIT,
|
||||
},
|
||||
}).exec();
|
||||
await user.save();
|
||||
try {
|
||||
await cronWrapper(req, res);
|
||||
} catch (err) {
|
||||
expect(err).to.exist;
|
||||
}
|
||||
});
|
||||
|
||||
it('increases status.times count and reruns up to 4 times', async () => {
|
||||
execStub.returns(Promise.resolve({ _cronSignature: 'RUNNING_CRON' }));
|
||||
execStub.onCall(4).returns(Promise.resolve({ _cronSignature: 'NOT_RUNNING' }));
|
||||
it('cronSignature longer than an hour ago should allow cron', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const now = new Date();
|
||||
await User.updateOne({
|
||||
_id: user._id,
|
||||
}, {
|
||||
$set: {
|
||||
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT - CRON_TIMEOUT_UNIT,
|
||||
},
|
||||
}).exec();
|
||||
await user.save();
|
||||
|
||||
await recoverCron(status, locals);
|
||||
|
||||
expect(status.times).to.eql(4);
|
||||
expect(locals.user).to.eql({ _cronSignature: 'NOT_RUNNING' });
|
||||
await cronWrapper(req, res);
|
||||
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
|
||||
expect(user._cronSignature).to.be.equal('NOT_RUNNING');
|
||||
});
|
||||
|
||||
it('throws an error if recoverCron runs 5 times', async () => {
|
||||
execStub.returns(Promise.resolve({ _cronSignature: 'RUNNING_CRON' }));
|
||||
it('cron should not run more than once', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
await user.save();
|
||||
|
||||
try {
|
||||
await recoverCron(status, locals);
|
||||
throw new Error('no exception when recoverCron runs 5 times');
|
||||
} catch (err) {
|
||||
expect(status.times).to.eql(5);
|
||||
expect(err.message).to.eql(`Impossible to recover from cron for user ${locals.user._id}.`);
|
||||
}
|
||||
const result = await Promise.allSettled([
|
||||
cronWrapper(req, res),
|
||||
cronWrapper(req, res),
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const runResult = await cronWrapper(req, res);
|
||||
if (runResult !== null) {
|
||||
reject(new Error('cron ran more than once'));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
}, 200);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.filter(r => r.status === 'fulfilled')).to.have.lengthOf(2);
|
||||
expect(result.filter(r => r.status === 'rejected')).to.have.lengthOf(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,23 +171,23 @@ describe('emails', () => {
|
||||
expect(got.post).not.to.be.called;
|
||||
});
|
||||
|
||||
it('throws error when mail target is only a string', () => {
|
||||
it('throws error when mail target is only a string', async () => {
|
||||
const emailType = 'an email type';
|
||||
const mailingInfo = 'my email';
|
||||
|
||||
expect(sendTxn(mailingInfo, emailType)).to.throw;
|
||||
await expect(sendTxn(mailingInfo, emailType)).to.be.rejectedWith('Argument Error mailingInfoArray: does not contain email or _id');
|
||||
});
|
||||
|
||||
it('throws error when mail target has no _id or email', () => {
|
||||
it('throws error when mail target has no _id or email', async () => {
|
||||
const emailType = 'an email type';
|
||||
const mailingInfo = {
|
||||
|
||||
};
|
||||
|
||||
expect(sendTxn(mailingInfo, emailType)).to.throw;
|
||||
await expect(sendTxn(mailingInfo, emailType)).to.be.rejectedWith('Argument Error mailingInfoArray: does not contain email or _id');
|
||||
});
|
||||
|
||||
it('throws error when variables not an array', () => {
|
||||
it('throws error when variables not an array', async () => {
|
||||
const emailType = 'an email type';
|
||||
const mailingInfo = {
|
||||
name: 'my name',
|
||||
@@ -195,9 +195,10 @@ describe('emails', () => {
|
||||
};
|
||||
const variables = {};
|
||||
|
||||
expect(sendTxn(mailingInfo, emailType, variables)).to.throw;
|
||||
await expect(sendTxn(mailingInfo, emailType, variables)).to.be.rejectedWith('Argument Error variables: is not an array');
|
||||
});
|
||||
it('throws error when variables array not contain name/content', () => {
|
||||
|
||||
it('throws error when variables array not contain name/content', async () => {
|
||||
const emailType = 'an email type';
|
||||
const mailingInfo = {
|
||||
name: 'my name',
|
||||
@@ -209,8 +210,9 @@ describe('emails', () => {
|
||||
},
|
||||
];
|
||||
|
||||
expect(sendTxn(mailingInfo, emailType, variables)).to.throw;
|
||||
await expect(sendTxn(mailingInfo, emailType, variables)).to.be.rejectedWith('Argument Error variables: does not contain name or content');
|
||||
});
|
||||
|
||||
it('throws no error when variables array contain name but no content', () => {
|
||||
const emailType = 'an email type';
|
||||
const mailingInfo = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os from 'os';
|
||||
import nconf from 'nconf';
|
||||
import requireAgain from 'require-again';
|
||||
|
||||
const pathToMongoLib = '../../../../website/server/libs/mongodb';
|
||||
@@ -29,22 +28,4 @@ describe('mongodb', () => {
|
||||
expect(string).to.equal('mongodb://hostname:3030');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultConnectionOptions', () => {
|
||||
it('returns development config when IS_PROD is false', () => {
|
||||
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
|
||||
const mongoLibOverride = requireAgain(pathToMongoLib);
|
||||
|
||||
const options = mongoLibOverride.getDefaultConnectionOptions();
|
||||
expect(options).to.have.all.keys(['useNewUrlParser', 'useUnifiedTopology']);
|
||||
});
|
||||
|
||||
it('returns production config when IS_PROD is true', () => {
|
||||
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
|
||||
const mongoLibOverride = requireAgain(pathToMongoLib);
|
||||
|
||||
const options = mongoLibOverride.getDefaultConnectionOptions();
|
||||
expect(options).to.have.all.keys(['useNewUrlParser', 'useUnifiedTopology']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
import moment from 'moment';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
generateRes,
|
||||
generateReq,
|
||||
generateTodo,
|
||||
generateDaily,
|
||||
} from '../../../helpers/api-unit.helper';
|
||||
import cronMiddleware from '../../../../website/server/middlewares/cron';
|
||||
import { model as User } from '../../../../website/server/models/user';
|
||||
import { model as Group } from '../../../../website/server/models/group';
|
||||
import * as Tasks from '../../../../website/server/models/task';
|
||||
import * as analyticsService from '../../../../website/server/libs/analyticsService';
|
||||
import * as cronLib from '../../../../website/server/libs/cron';
|
||||
|
||||
const CRON_TIMEOUT_WAIT = new Date(60 * 60 * 1000).getTime();
|
||||
const CRON_TIMEOUT_UNIT = new Date(60 * 1000).getTime();
|
||||
|
||||
describe('cron middleware', () => {
|
||||
let res; let
|
||||
req;
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
res = generateRes();
|
||||
req = generateReq();
|
||||
user = await res.locals.user.save();
|
||||
res.analytics = analyticsService;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('calls next when user is not attached', done => {
|
||||
res.locals.user = null;
|
||||
cronMiddleware(req, res, done);
|
||||
});
|
||||
|
||||
it('calls next when days have not been missed', done => {
|
||||
cronMiddleware(req, res, done);
|
||||
});
|
||||
|
||||
it('should clear todos older than 30 days for free users', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const task = generateTodo(user);
|
||||
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
|
||||
task.completed = true;
|
||||
await task.save();
|
||||
await user.save();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
|
||||
Tasks.Task.findOne({ _id: task }).then(foundTask => {
|
||||
expect(foundTask).to.not.exist;
|
||||
resolve();
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should not clear todos older than 30 days for subscribed users', async () => {
|
||||
user.purchased.plan.customerId = 'subscribedId';
|
||||
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const task = generateTodo(user);
|
||||
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
|
||||
task.completed = true;
|
||||
await task.save();
|
||||
await user.save();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
Tasks.Task.findOne({ _id: task }).then(foundTask => {
|
||||
expect(foundTask).to.exist;
|
||||
return resolve();
|
||||
});
|
||||
return null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear todos older than 90 days for subscribed users', async () => {
|
||||
user.purchased.plan.customerId = 'subscribedId';
|
||||
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
|
||||
const task = generateTodo(user);
|
||||
task.dateCompleted = moment(new Date()).subtract({ days: 91 });
|
||||
task.completed = true;
|
||||
await task.save();
|
||||
await user.save();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
Tasks.Task.findOne({ _id: task }).then(foundTask => {
|
||||
expect(foundTask).to.not.exist;
|
||||
return resolve();
|
||||
});
|
||||
return null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call next if user was not modified after cron', async () => {
|
||||
const hpBefore = user.stats.hp;
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
await user.save();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
expect(hpBefore).to.equal(user.stats.hp);
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('runs cron if previous cron was incomplete', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 1 });
|
||||
user.auth.timestamps.loggedin = moment(new Date()).subtract({ days: 4 });
|
||||
const now = new Date();
|
||||
await user.save();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
expect(moment(now).isSame(user.lastCron, 'day'));
|
||||
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('updates user.auth.timestamps.loggedin and lastCron', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const now = new Date();
|
||||
await user.save();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
expect(moment(now).isSame(user.lastCron, 'day'));
|
||||
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does damage for missing dailies', async () => {
|
||||
const hpBefore = user.stats.hp;
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const daily = generateDaily(user);
|
||||
daily.startDate = moment(new Date()).subtract({ days: 2 });
|
||||
await daily.save();
|
||||
await user.save();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
return User.findOne({ _id: user._id }).then(updatedUser => {
|
||||
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('updates tasks', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const todo = generateTodo(user);
|
||||
const todoValueBefore = todo.value;
|
||||
await Promise.all([todo.save(), user.save()]);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
return Tasks.Task.findOne({ _id: todo._id }).then(todoFound => {
|
||||
expect(todoFound.value).to.be.lessThan(todoValueBefore);
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('applies quest progress', async () => {
|
||||
const hpBefore = user.stats.hp;
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const daily = generateDaily(user);
|
||||
daily.startDate = moment(new Date()).subtract({ days: 2 });
|
||||
await daily.save();
|
||||
|
||||
const questKey = 'dilatory';
|
||||
user.party.quest.key = questKey;
|
||||
|
||||
const party = new Group({
|
||||
type: 'party',
|
||||
name: generateUUID(),
|
||||
leader: user._id,
|
||||
});
|
||||
party.quest.members[user._id] = true;
|
||||
party.quest.key = questKey;
|
||||
await party.save();
|
||||
|
||||
user.party._id = party._id;
|
||||
await user.save();
|
||||
|
||||
party.startQuest(user);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
return User.findOne({ _id: user._id }).then(updatedUser => {
|
||||
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers from failed cron and does not error when user is already cronning', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
await user.save();
|
||||
|
||||
const updatedUser = user.toObject();
|
||||
updatedUser.matchedCount = 0;
|
||||
|
||||
sandbox.spy(cronLib, 'recoverCron');
|
||||
|
||||
sandbox.stub(User, 'updateOne')
|
||||
.withArgs({
|
||||
_id: user._id,
|
||||
$or: [
|
||||
{ _cronSignature: 'NOT_RUNNING' },
|
||||
{ _cronSignature: { $lt: sinon.match.number } },
|
||||
],
|
||||
})
|
||||
.returns({
|
||||
exec () {
|
||||
return Promise.resolve(updatedUser);
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
expect(cronLib.recoverCron).to.be.calledOnce;
|
||||
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('cronSignature less than an hour ago should error', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const now = new Date();
|
||||
await User.updateOne({
|
||||
_id: user._id,
|
||||
}, {
|
||||
$set: {
|
||||
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT + CRON_TIMEOUT_UNIT,
|
||||
},
|
||||
}).exec();
|
||||
await user.save();
|
||||
const expectedErrMessage = `Impossible to recover from cron for user ${user._id}.`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (!err) return reject(new Error('Cron should have failed.'));
|
||||
expect(err.message).to.be.equal(expectedErrMessage);
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('cronSignature longer than an hour ago should allow cron', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
const now = new Date();
|
||||
await User.updateOne({
|
||||
_id: user._id,
|
||||
}, {
|
||||
$set: {
|
||||
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT - CRON_TIMEOUT_UNIT,
|
||||
},
|
||||
}).exec();
|
||||
await user.save();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
|
||||
expect(user._cronSignature).to.be.equal('NOT_RUNNING');
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('cron should not run more than once', async () => {
|
||||
user.lastCron = moment(new Date()).subtract({ days: 2 });
|
||||
await user.save();
|
||||
|
||||
sandbox.spy(cronLib, 'cron');
|
||||
|
||||
await Promise.all([new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
return resolve();
|
||||
});
|
||||
}), new Promise((resolve, reject) => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
return resolve();
|
||||
});
|
||||
}), new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
cronMiddleware(req, res, err => {
|
||||
if (err) return reject(err);
|
||||
return resolve();
|
||||
});
|
||||
}, 400);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(cronLib.cron).to.be.calledOnce;
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ describe('GET /heroes/:heroId', () => {
|
||||
const heroFields = [
|
||||
'_id', 'id', 'auth', 'balance', 'contributor', 'flags', 'items',
|
||||
'lastCron', 'party', 'preferences', 'profile', 'purchased', 'secret', 'achievements',
|
||||
'stats',
|
||||
];
|
||||
|
||||
before(async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('PUT /heroes/:heroId', () => {
|
||||
const heroFields = [
|
||||
'_id', 'auth', 'balance', 'contributor', 'flags', 'items', 'lastCron',
|
||||
'party', 'preferences', 'profile', 'purchased', 'secret', 'permissions', 'achievements',
|
||||
'stats',
|
||||
];
|
||||
|
||||
before(async () => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
generateUser,
|
||||
translate as t,
|
||||
} from '../../../../helpers/api-integration/v3';
|
||||
import common from '../../../../../website/common';
|
||||
|
||||
describe('GET /members/username/:username', () => {
|
||||
let user;
|
||||
|
||||
before(async () => {
|
||||
user = await generateUser();
|
||||
});
|
||||
|
||||
it('validates req.params.username', async () => {
|
||||
await expect(user.get('/members/username/')).to.eventually.be.rejected.and.eql({
|
||||
code: 400,
|
||||
error: 'BadRequest',
|
||||
message: t('invalidReqParams'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a member\'s public data only', async () => {
|
||||
// make sure user has all the fields that can be returned by the getMember call
|
||||
const member = await generateUser({
|
||||
contributor: { level: 1 },
|
||||
backer: { tier: 3 },
|
||||
preferences: {
|
||||
costume: false,
|
||||
background: 'volcano',
|
||||
},
|
||||
secret: {
|
||||
text: 'Clark Kent',
|
||||
},
|
||||
});
|
||||
const memberRes = await user.get(`/members/username/${member.auth.local.username}`);
|
||||
expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys
|
||||
'_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
|
||||
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags',
|
||||
]);
|
||||
expect(Object.keys(memberRes.auth)).to.eql(['local', 'timestamps']);
|
||||
expect(Object.keys(memberRes.preferences).sort()).to.eql([
|
||||
'size', 'hair', 'skin', 'shirt',
|
||||
'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses',
|
||||
].sort());
|
||||
|
||||
expect(memberRes.stats.maxMP).to.exist;
|
||||
expect(memberRes.stats.maxHealth).to.equal(common.maxHealth);
|
||||
expect(memberRes.stats.toNextLevel).to.equal(common.tnl(memberRes.stats.lvl));
|
||||
expect(memberRes.inbox.optOut).to.exist;
|
||||
expect(memberRes.inbox.canReceive).to.exist;
|
||||
expect(memberRes.inbox.messages).to.not.exist;
|
||||
expect(memberRes.secret).to.not.exist;
|
||||
|
||||
expect(memberRes.blocks).to.not.exist;
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,18 @@ describe('POST /user/auth/social', () => {
|
||||
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
|
||||
});
|
||||
|
||||
it('fails if allowRegister is false and user does not exist', async () => {
|
||||
await expect(api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
allowRegister: false,
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 404,
|
||||
error: 'NotFound',
|
||||
message: t('userNotFound'),
|
||||
});
|
||||
});
|
||||
|
||||
it('logs an existing user in', async () => {
|
||||
const registerResponse = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
@@ -131,6 +143,36 @@ describe('POST /user/auth/social', () => {
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
|
||||
it('logs an existing user into their social account if allowRegister is false', async () => {
|
||||
const registerResponse = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
});
|
||||
expect(registerResponse.newUser).to.be.true;
|
||||
// This is important for existing accounts before the new social handling
|
||||
passport._strategies.google.userProfile.restore();
|
||||
const expectedResult = {
|
||||
id: randomGoogleId,
|
||||
displayName: 'a google user',
|
||||
emails: [
|
||||
{ value: user.auth.local.email },
|
||||
],
|
||||
};
|
||||
sandbox.stub(passport._strategies.google, 'userProfile').yields(null, expectedResult);
|
||||
|
||||
const response = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
allowRegister: false,
|
||||
});
|
||||
|
||||
expect(response.apiToken).to.eql(registerResponse.apiToken);
|
||||
expect(response.id).to.eql(registerResponse.id);
|
||||
expect(response.apiToken).not.to.eql(user.apiToken);
|
||||
expect(response.id).not.to.eql(user._id);
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
|
||||
it('add social auth to an existing user', async () => {
|
||||
const response = await user.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import find from 'lodash/find';
|
||||
import {
|
||||
generateUser,
|
||||
translate as t,
|
||||
} from '../../../helpers/api-integration/v4';
|
||||
|
||||
/**
|
||||
* Checks the messages array if the uniqueMessageId has the like flag
|
||||
* @param {InboxMessage[]} messages
|
||||
* @param {String} uniqueMessageId
|
||||
* @param {String} userId
|
||||
* @param {Boolean} likeStatus
|
||||
*/
|
||||
function expectMessagesLikeStatus (messages, uniqueMessageId, userId, likeStatus) {
|
||||
const messageToCheck = find(messages, { uniqueMessageId });
|
||||
|
||||
expect(messageToCheck.likes[userId]).to.equal(likeStatus);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line mocha/no-exclusive-tests
|
||||
describe('POST /inbox/like-private-message/:messageId', () => {
|
||||
let userToSendMessage;
|
||||
const getLikeUrl = messageId => `/inbox/like-private-message/${messageId}`;
|
||||
|
||||
before(async () => {
|
||||
userToSendMessage = await generateUser();
|
||||
});
|
||||
|
||||
it('returns an error when private message is not found', async () => {
|
||||
await expect(userToSendMessage.post(getLikeUrl('some-unknown-id')))
|
||||
.to.eventually.be.rejected.and.eql({
|
||||
code: 404,
|
||||
error: 'NotFound',
|
||||
message: t('messageGroupChatNotFound'),
|
||||
});
|
||||
});
|
||||
|
||||
it('likes a message', async () => {
|
||||
const receiver = await generateUser();
|
||||
|
||||
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
|
||||
message: 'some message :)',
|
||||
toUserId: receiver._id,
|
||||
});
|
||||
|
||||
const { uniqueMessageId } = sentMessageResult.message;
|
||||
|
||||
const likeResult = await receiver.post(getLikeUrl(uniqueMessageId));
|
||||
expect(likeResult.likes[receiver._id]).to.equal(true);
|
||||
|
||||
const senderMessages = await userToSendMessage.get('/inbox/messages');
|
||||
|
||||
expectMessagesLikeStatus(senderMessages, uniqueMessageId, receiver._id, true);
|
||||
|
||||
const receiversMessages = await receiver.get('/inbox/messages');
|
||||
|
||||
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, receiver._id, true);
|
||||
});
|
||||
|
||||
it('allows a user to like their own private message', async () => {
|
||||
const receiver = await generateUser();
|
||||
|
||||
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
|
||||
message: 'some message :)',
|
||||
toUserId: receiver._id,
|
||||
});
|
||||
|
||||
const { uniqueMessageId } = sentMessageResult.message;
|
||||
|
||||
const likeResult = await userToSendMessage.post(getLikeUrl(uniqueMessageId));
|
||||
expect(likeResult.likes[userToSendMessage._id]).to.equal(true);
|
||||
|
||||
const messages = await userToSendMessage.get('/inbox/messages');
|
||||
expectMessagesLikeStatus(messages, uniqueMessageId, userToSendMessage._id, true);
|
||||
|
||||
const receiversMessages = await receiver.get('/inbox/messages');
|
||||
|
||||
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, userToSendMessage._id, true);
|
||||
});
|
||||
|
||||
it('unlikes a message', async () => {
|
||||
const receiver = await generateUser();
|
||||
|
||||
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
|
||||
message: 'some message :)',
|
||||
toUserId: receiver._id,
|
||||
});
|
||||
|
||||
const { uniqueMessageId } = sentMessageResult.message;
|
||||
|
||||
const likeResult = await receiver.post(getLikeUrl(uniqueMessageId));
|
||||
|
||||
expect(likeResult.likes[receiver._id]).to.equal(true);
|
||||
|
||||
const unlikeResult = await receiver.post(getLikeUrl(uniqueMessageId));
|
||||
|
||||
expect(unlikeResult.likes[receiver._id]).to.equal(false);
|
||||
|
||||
const messages = await userToSendMessage.get('/inbox/messages');
|
||||
|
||||
const messageToCheck = find(messages, { id: sentMessageResult.message.id });
|
||||
expect(messageToCheck.likes[receiver._id]).to.equal(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
translate as t,
|
||||
requester,
|
||||
generateUser,
|
||||
} from '../../../../helpers/api-integration/v4';
|
||||
|
||||
const ENDPOINT = '/user/auth/check-email';
|
||||
|
||||
describe('POST /user/auth/check-email', () => {
|
||||
const email = 'SOmE-nEw-emAIl_2@example.net';
|
||||
let api;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = requester();
|
||||
});
|
||||
|
||||
it('returns email if it is not used yet', async () => {
|
||||
const response = await api.post(ENDPOINT, {
|
||||
email,
|
||||
});
|
||||
expect(response.email).to.eql(email);
|
||||
});
|
||||
|
||||
it('rejects if email is not provided', async () => {
|
||||
await expect(api.post(ENDPOINT, {
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 400,
|
||||
error: 'BadRequest',
|
||||
message: 'Invalid request parameters.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects if email is already taken', async () => {
|
||||
const user = await generateUser();
|
||||
|
||||
await expect(api.post(ENDPOINT, {
|
||||
email: user.auth.local.email,
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('emailTaken'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects if casing is different', async () => {
|
||||
const user = await generateUser();
|
||||
|
||||
await expect(api.post(ENDPOINT, {
|
||||
email: user.auth.local.email.toUpperCase(),
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('emailTaken'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe('Content Schedule', () => {
|
||||
const date = new Date('2024-04-15');
|
||||
const matchers = getAllScheduleMatchingGroups(date);
|
||||
expect(matchers.premiumHatchingPotions).to.exist;
|
||||
expect(matchers.premiumHatchingPotions.items.length).to.equal(5);
|
||||
expect(matchers.premiumHatchingPotions.items.length).to.equal(6);
|
||||
expect(matchers.premiumHatchingPotions.items.indexOf('Veggie')).to.not.equal(-1);
|
||||
expect(matchers.premiumHatchingPotions.items.indexOf('Porcelain')).to.not.equal(-1);
|
||||
});
|
||||
|
||||
@@ -74,15 +74,10 @@ export async function getDocument (collectionName, doc) {
|
||||
}
|
||||
|
||||
before(done => {
|
||||
mongoose.connection.on('open', err => {
|
||||
if (err) return done(err);
|
||||
return resetHabiticaDB()
|
||||
.then(() => {
|
||||
done();
|
||||
})
|
||||
.catch(error => {
|
||||
throw error;
|
||||
});
|
||||
mongoose.connection.once('open', async err => {
|
||||
if (err) throw err;
|
||||
await resetHabiticaDB();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ module.exports = {
|
||||
extends: [
|
||||
'habitrpg/lib/vue',
|
||||
],
|
||||
ignorePatterns: ['dist/', 'node_modules/'],
|
||||
ignorePatterns: ['dist/', 'node_modules/', '*.d.ts'],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
height: 219px;
|
||||
}
|
||||
|
||||
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup, .Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi {
|
||||
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup,
|
||||
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
}
|
||||
@@ -47,6 +48,10 @@
|
||||
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Fungi.gif") no-repeat;
|
||||
}
|
||||
|
||||
.Pet_HatchingPotion_Cryptid {
|
||||
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Cryptid.gif") no-repeat;
|
||||
}
|
||||
|
||||
.Gems {
|
||||
display:inline-block;
|
||||
margin-right:5px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@
|
||||
top: -16px !important;
|
||||
}
|
||||
|
||||
$foolPets: Veggie, Dessert, VirtualPet, TeaShop, Fungi;
|
||||
$foolPets: Veggie, Dessert, VirtualPet, TeaShop, Fungi, Cryptid;
|
||||
|
||||
@each $foolPet in $foolPets {
|
||||
.Pet.Pet-FlyingPig-#{$foolPet} {
|
||||
|
||||
@@ -101,8 +101,7 @@
|
||||
|
||||
.btn-secondary,
|
||||
.dropdown > .btn-secondary.dropdown-toggle:not(.btn-success),
|
||||
.show > .btn-secondary.dropdown-toggle:not(.btn-success)
|
||||
{
|
||||
.show > .btn-secondary.dropdown-toggle:not(.btn-success) {
|
||||
background: $white;
|
||||
border: 2px solid transparent;
|
||||
color: $gray-50;
|
||||
@@ -298,6 +297,16 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-flat,
|
||||
.dropdown > .btn-flat.dropdown-toggle:not(.btn-success),
|
||||
.show > .btn-flat.dropdown-toggle:not(.btn-success) {
|
||||
&.with-icon {
|
||||
.svg-icon.color {
|
||||
color: var(--icon-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
color: $blue-10;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,12 @@
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.no-min-width {
|
||||
.dropdown-menu {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// shared dropdown-item styles
|
||||
@@ -54,6 +59,8 @@
|
||||
color: $gray-50 !important;
|
||||
cursor: pointer;
|
||||
|
||||
--dropdown-item-hover-icon-color: #{$gray-200};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: inherit;
|
||||
@@ -88,7 +95,7 @@
|
||||
|
||||
&:not(:hover) {
|
||||
.with-icon .svg-icon {
|
||||
color: $gray-200;
|
||||
color: var(dropdown-item-hover-icon-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +158,7 @@
|
||||
|
||||
// selectList.vue items sizing
|
||||
.selectListItem .dropdown-item {
|
||||
padding: 0.25rem 0.75rem;
|
||||
padding: 0.25rem 1rem 0.25rem 0.75rem;
|
||||
height: 32px;
|
||||
|
||||
&:active, &:hover, &:focus, &.active {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div :class="questClass"></div>
|
||||
<Sprite :image-name="questClass" />
|
||||
</section>
|
||||
<!-- @TODO: Keep this? .checkboxinput(type='checkbox', v-model=
|
||||
'user.preferences.suppressModals.levelUp', @change='changeLevelupSuppress()')
|
||||
@@ -150,15 +150,12 @@ label(style='display:inline-block') {{ $t('dontShowAgain') }}
|
||||
section.greyed {
|
||||
padding-bottom: 17px
|
||||
}
|
||||
|
||||
.scroll {
|
||||
margin: -11px auto 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Avatar from '../avatar';
|
||||
import Sprite from '@/components/ui/sprite';
|
||||
import { mapState } from '@/libs/store';
|
||||
import starGroup from '@/assets/svg/star-group.svg';
|
||||
import sparkles from '@/assets/svg/sparkles-left.svg';
|
||||
@@ -173,6 +170,7 @@ const levelQuests = {
|
||||
export default {
|
||||
components: {
|
||||
Avatar,
|
||||
Sprite,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -191,7 +189,9 @@ export default {
|
||||
return this.user.stats.lvl in levelQuests;
|
||||
},
|
||||
questClass () {
|
||||
return `scroll inventory_quest_scroll_${levelQuests[this.user.stats.lvl]}`;
|
||||
const questKey = levelQuests[this.user.stats.lvl];
|
||||
if (questKey) return `inventory_quest_scroll_${questKey}`;
|
||||
return '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -92,8 +92,6 @@ export default {
|
||||
params: { userIdentifier },
|
||||
}).catch(failure => {
|
||||
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
// the admin has requested that the same user be displayed again so reload the page
|
||||
// (e.g., if they changed their mind about changes they were making)
|
||||
this.$router.go();
|
||||
}
|
||||
});
|
||||
@@ -101,14 +99,16 @@ export default {
|
||||
|
||||
async loadUser (userIdentifier) {
|
||||
const id = userIdentifier || this.user._id;
|
||||
|
||||
this.$router.push({
|
||||
if (this.$router.currentRoute.name === 'adminPanelUser') {
|
||||
await this.$router.push({
|
||||
name: 'adminPanel',
|
||||
});
|
||||
}
|
||||
await this.$router.push({
|
||||
name: 'adminPanelUser',
|
||||
params: { userIdentifier: id },
|
||||
}).catch(failure => {
|
||||
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
// the admin has requested that the same user be displayed again so reload the page
|
||||
// (e.g., if they changed their mind about changes they were making)
|
||||
this.$router.go();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import VueRouter from 'vue-router';
|
||||
|
||||
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
async saveHero ({ hero, msg = 'User', clearData }) {
|
||||
async saveHero ({
|
||||
hero,
|
||||
msg = 'User',
|
||||
clearData,
|
||||
reloadData,
|
||||
}) {
|
||||
await this.$store.dispatch('hall:updateHero', { heroDetails: hero });
|
||||
await this.$store.dispatch('snackbars:add', {
|
||||
title: '',
|
||||
@@ -14,6 +23,20 @@ export default {
|
||||
// The admin should re-fetch the data if they need to keep working on that user.
|
||||
this.$emit('clear-data');
|
||||
this.$router.push({ name: 'adminPanel' });
|
||||
} else if (reloadData) {
|
||||
if (this.$router.currentRoute.name === 'adminPanelUser') {
|
||||
await this.$router.push({
|
||||
name: 'adminPanel',
|
||||
});
|
||||
}
|
||||
await this.$router.push({
|
||||
name: 'adminPanelUser',
|
||||
params: { userIdentifier: hero._id },
|
||||
}).catch(failure => {
|
||||
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
this.$router.go();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
>
|
||||
Could not find any matching users.
|
||||
</div>
|
||||
<loading-spinner class="mx-auto mb-2" dark-color="true" v-if="isSearching" />
|
||||
<loading-spinner
|
||||
v-if="isSearching"
|
||||
class="mx-auto mb-2"
|
||||
dark-color="true"
|
||||
/>
|
||||
<div
|
||||
v-if="users.length > 0"
|
||||
class="list-group"
|
||||
@@ -59,6 +63,10 @@ export default {
|
||||
components: {
|
||||
LoadingSpinner,
|
||||
},
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
this.userIdentifier = to.params.userIdentifier;
|
||||
next();
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
userIdentifier: '',
|
||||
@@ -70,10 +78,6 @@ export default {
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
this.userIdentifier = to.params.userIdentifier;
|
||||
next();
|
||||
},
|
||||
watch: {
|
||||
userIdentifier () {
|
||||
this.isSearching = true;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<li
|
||||
v-for="item in achievements"
|
||||
:key="item.path"
|
||||
v-b-tooltip.hover="item.notes"
|
||||
>
|
||||
<form @submit.prevent="saveItem(item)">
|
||||
<span
|
||||
@@ -27,7 +28,7 @@
|
||||
{{ item.value }}
|
||||
</span>
|
||||
:
|
||||
{{ itemText(item) }}
|
||||
{{ item.text || item.key }} - <i> {{ item.key }} </i>
|
||||
</span>
|
||||
|
||||
<div
|
||||
@@ -68,6 +69,7 @@
|
||||
<li
|
||||
v-for="item in nestedAchievements[achievementType]"
|
||||
:key="item.path"
|
||||
v-b-tooltip.hover="item.notes"
|
||||
>
|
||||
<form @submit.prevent="saveItem(item)">
|
||||
<span
|
||||
@@ -78,7 +80,7 @@
|
||||
{{ item.value }}
|
||||
</span>
|
||||
:
|
||||
{{ itemText(item) }}
|
||||
{{ item.text || item.key }} - <i> {{ item.key }} </i>
|
||||
</span>
|
||||
|
||||
<div
|
||||
@@ -143,79 +145,28 @@ function getText (achievementItem) {
|
||||
}
|
||||
const { titleKey } = achievementItem;
|
||||
if (titleKey !== undefined) {
|
||||
return i18n.t(titleKey, 'en');
|
||||
return i18n.t(titleKey);
|
||||
}
|
||||
const { singularTitleKey } = achievementItem;
|
||||
if (singularTitleKey !== undefined) {
|
||||
return i18n.t(singularTitleKey, 'en');
|
||||
return i18n.t(singularTitleKey);
|
||||
}
|
||||
return achievementItem.key;
|
||||
}
|
||||
|
||||
function collateItemData (self) {
|
||||
const achievements = [];
|
||||
const nestedAchievements = {};
|
||||
const basePath = 'achievements';
|
||||
const ownedAchievements = self.hero.achievements;
|
||||
const allAchievements = content.achievements;
|
||||
|
||||
for (const key of Object.keys(ownedAchievements)) {
|
||||
const value = ownedAchievements[key];
|
||||
if (typeof value === 'object') {
|
||||
nestedAchievements[key] = [];
|
||||
for (const nestedKey of Object.keys(value)) {
|
||||
const valueIsInteger = self.integerTypes.includes(key);
|
||||
let text = nestedKey;
|
||||
if (allAchievements[key] && allAchievements[key][nestedKey]) {
|
||||
text = getText(allAchievements[key][nestedKey]);
|
||||
}
|
||||
nestedAchievements[key].push({
|
||||
key: nestedKey,
|
||||
text,
|
||||
achievementType: key,
|
||||
modified: false,
|
||||
path: `${basePath}.${key}.${nestedKey}`,
|
||||
value: value[nestedKey],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const valueIsInteger = self.integerTypes.includes(key);
|
||||
achievements.push({
|
||||
key,
|
||||
text: getText(allAchievements[key]),
|
||||
modified: false,
|
||||
path: `${basePath}.${key}`,
|
||||
value: ownedAchievements[key],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
function getNotes (achievementItem, count) {
|
||||
if (achievementItem === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (const key of Object.keys(allAchievements)) {
|
||||
if (key !== '' && !key.endsWith('UltimateGear') && !key.endsWith('Quest')) {
|
||||
if (ownedAchievements[key] === undefined) {
|
||||
const valueIsInteger = self.integerTypes.includes(key);
|
||||
achievements.push({
|
||||
key,
|
||||
text: getText(allAchievements[key]),
|
||||
modified: false,
|
||||
path: `${basePath}.${key}`,
|
||||
value: valueIsInteger ? 0 : false,
|
||||
valueIsInteger,
|
||||
neverOwned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const { textKey } = achievementItem;
|
||||
if (textKey !== undefined) {
|
||||
return i18n.t(textKey, { count });
|
||||
}
|
||||
|
||||
self.achievements = achievements;
|
||||
self.nestedAchievements = nestedAchievements;
|
||||
}
|
||||
|
||||
function resetData (self) {
|
||||
collateItemData(self);
|
||||
self.nestedAchievementKeys.forEach(itemType => { self.expandItemType[itemType] = false; });
|
||||
const { singularTextKey } = achievementItem;
|
||||
if (singularTextKey !== undefined) {
|
||||
return i18n.t(singularTextKey, { count });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -241,26 +192,34 @@ export default {
|
||||
},
|
||||
nestedAchievementKeys: ['quests', 'ultimateGearSets'],
|
||||
integerTypes: ['streak', 'perfect', 'birthday', 'habiticaDays', 'habitSurveys', 'habitBirthdays',
|
||||
'valentine', 'congrats', 'shinySeed', 'goodluck', 'thankyou', 'seafoam', 'snowball', 'quests'],
|
||||
'valentine', 'congrats', 'shinySeed', 'goodluck', 'thankyou', 'seafoam', 'snowball', 'quests',
|
||||
'rebirths', 'rebirthLevel', 'greeting', 'spookySparkles', 'nye', 'costumeContests', 'congrats',
|
||||
'getwell', 'beastMasterCount', 'mountMasterCount', 'triadBingoCount',
|
||||
],
|
||||
cardTypes: ['greeting', 'birthday', 'valentine', 'goodluck', 'thankyou', 'greeting', 'nye',
|
||||
'congrats', 'getwell'],
|
||||
achievements: [],
|
||||
nestedAchievements: {},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
this.resetData();
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
this.resetData();
|
||||
},
|
||||
methods: {
|
||||
async saveItem (item) {
|
||||
// prepare the item's new value and path for being saved
|
||||
this.hero.achievementPath = item.path;
|
||||
this.hero.achievementVal = item.value;
|
||||
|
||||
await this.saveHero({ hero: this.hero, msg: item.path });
|
||||
await this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
achievementPath: item.path,
|
||||
achievementVal: item.value,
|
||||
},
|
||||
msg: item.path,
|
||||
});
|
||||
item.modified = false;
|
||||
},
|
||||
enableValueChange (item) {
|
||||
@@ -270,14 +229,84 @@ export default {
|
||||
item.value = !item.value;
|
||||
}
|
||||
},
|
||||
itemText (item) {
|
||||
if (item.key === 'npc') {
|
||||
return this.$t('npcAchievementName', { key: this.hero.backer && this.hero.backer.npc });
|
||||
resetData () {
|
||||
this.collateItemData();
|
||||
this.nestedAchievementKeys.forEach(itemType => { this.expandItemType[itemType] = false; });
|
||||
},
|
||||
collateItemData () {
|
||||
const achievements = [];
|
||||
const nestedAchievements = {};
|
||||
const basePath = 'achievements';
|
||||
const ownedAchievements = this.hero.achievements;
|
||||
const allAchievements = content.achievements;
|
||||
|
||||
const ownedKeys = Object.keys(ownedAchievements).sort();
|
||||
for (const key of ownedKeys) {
|
||||
const value = ownedAchievements[key];
|
||||
let contentKey = key;
|
||||
if (this.cardTypes.indexOf(key) !== -1) {
|
||||
contentKey += 'Cards';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
nestedAchievements[key] = [];
|
||||
for (const nestedKey of Object.keys(value)) {
|
||||
const valueIsInteger = this.integerTypes.includes(key);
|
||||
let text = nestedKey;
|
||||
if (allAchievements[key] && allAchievements[key][contentKey]) {
|
||||
text = getText(allAchievements[key][contentKey]);
|
||||
}
|
||||
let notes = '';
|
||||
if (allAchievements[key] && allAchievements[key][contentKey]) {
|
||||
notes = getNotes(allAchievements[key][contentKey], ownedAchievements[key]);
|
||||
}
|
||||
nestedAchievements[key].push({
|
||||
key: nestedKey,
|
||||
text,
|
||||
notes,
|
||||
achievementType: key,
|
||||
modified: false,
|
||||
path: `${basePath}.${key}.${nestedKey}`,
|
||||
value: value[nestedKey],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const valueIsInteger = this.integerTypes.includes(key);
|
||||
achievements.push({
|
||||
key,
|
||||
text: getText(allAchievements[contentKey]),
|
||||
notes: getNotes(allAchievements[contentKey], ownedAchievements[key]),
|
||||
modified: false,
|
||||
path: `${basePath}.${key}`,
|
||||
value: ownedAchievements[key],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (item.key === 'kickstarter') {
|
||||
return this.$t('kickstartName', { key: this.hero.backer && this.hero.backer.tier });
|
||||
|
||||
const allKeys = Object.keys(allAchievements).sort();
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key !== '' && !key.endsWith('UltimateGear') && !key.endsWith('Quest')) {
|
||||
const ownedKey = key.replace('Cards', '');
|
||||
if (ownedAchievements[ownedKey] === undefined) {
|
||||
const valueIsInteger = this.integerTypes.includes(ownedKey);
|
||||
achievements.push({
|
||||
key: ownedKey,
|
||||
text: getText(allAchievements[key]),
|
||||
notes: getNotes(allAchievements[key], 0),
|
||||
modified: false,
|
||||
path: `${basePath}.${ownedKey}`,
|
||||
value: valueIsInteger ? 0 : false,
|
||||
valueIsInteger,
|
||||
neverOwned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return item.text || item.key;
|
||||
|
||||
this.achievements = achievements;
|
||||
this.nestedAchievements = nestedAchievements;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<form @submit.prevent="saveHero({ hero, msg: 'Contributor details', clearData: true })">
|
||||
<form
|
||||
@submit.prevent="saveHero({ hero: {
|
||||
_id: hero._id,
|
||||
contributor: hero.contributor,
|
||||
secret: hero.secret,
|
||||
permissions: hero.permissions,
|
||||
}, msg: 'Contributor details', clearData: true })"
|
||||
>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
@@ -8,6 +15,12 @@
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Contributor Details
|
||||
<b
|
||||
v-if="hasUnsavedChanges && !expand"
|
||||
class="text-warning float-right"
|
||||
>
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
@@ -104,13 +117,16 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -190,6 +206,10 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<form @submit.prevent="saveHero({ hero, msg: 'Authentication' })">
|
||||
<form
|
||||
@submit.prevent="saveHero({ hero: {
|
||||
_id: hero._id,
|
||||
auth: hero.auth,
|
||||
preferences: hero.preferences,
|
||||
}, msg: 'Authentication' })"
|
||||
>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
@@ -38,7 +44,10 @@
|
||||
<strong v-else>No</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cronError" class="form-group row">
|
||||
<div
|
||||
v-if="cronError"
|
||||
class="form-group row"
|
||||
>
|
||||
<label class="col-sm-3 col-form-label">lastCron value:</label>
|
||||
<strong>{{ hero.lastCron | formatDate }}</strong>
|
||||
<br>
|
||||
@@ -53,12 +62,12 @@
|
||||
<div class="col-sm-9 col-form-label">
|
||||
<strong>
|
||||
{{ hero.auth.timestamps.loggedin | formatDate }}</strong>
|
||||
<button
|
||||
<a
|
||||
class="btn btn-warning btn-sm ml-4"
|
||||
@click="resetCron()"
|
||||
>
|
||||
Reset Cron to Yesterday
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
@@ -110,13 +119,14 @@
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">API Token</label>
|
||||
<div class="col-sm-9">
|
||||
<button
|
||||
<a
|
||||
href="#"
|
||||
value="Change API Token"
|
||||
class="btn btn-danger"
|
||||
@click="changeApiToken()"
|
||||
>
|
||||
Change API Token
|
||||
</button>
|
||||
</a>
|
||||
<div
|
||||
v-if="tokenModified"
|
||||
>
|
||||
@@ -268,13 +278,24 @@ export default {
|
||||
return false;
|
||||
},
|
||||
async changeApiToken () {
|
||||
this.hero.changeApiToken = true;
|
||||
await this.saveHero({ hero: this.hero, msg: 'API Token' });
|
||||
await this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
changeApiToken: true,
|
||||
},
|
||||
msg: 'API Token',
|
||||
});
|
||||
this.tokenModified = true;
|
||||
},
|
||||
resetCron () {
|
||||
this.hero.resetCron = true;
|
||||
this.saveHero({ hero: this.hero, msg: 'Last Cron', clearData: true });
|
||||
this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
resetCron: true,
|
||||
},
|
||||
msg: 'Last Cron',
|
||||
clearData: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
:
|
||||
<span :class="{ ownedItem: !item.neverOwned }">{{ item.text }}</span>
|
||||
</span>
|
||||
{{ item.set }}
|
||||
- {{ itemType }}.{{item.key}} - <i> {{ item.set }}</i>
|
||||
|
||||
<div
|
||||
v-if="item.modified"
|
||||
@@ -232,11 +232,14 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async saveItem (item) {
|
||||
// prepare the item's new value and path for being saved
|
||||
this.hero.purchasedPath = item.path;
|
||||
this.hero.purchasedVal = item.value;
|
||||
|
||||
await this.saveHero({ hero: this.hero, msg: item.path });
|
||||
await this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
purchasedPath: item.path,
|
||||
purchasedVal: item.value,
|
||||
},
|
||||
msg: item.path,
|
||||
});
|
||||
item.modified = false;
|
||||
},
|
||||
enableValueChange (item) {
|
||||
|
||||
@@ -15,10 +15,17 @@
|
||||
<privileges-and-gems
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
:has-unsaved-changes="hasUnsavedChanges([hero.flags, unModifiedHero.flags],
|
||||
[hero.auth, unModifiedHero.auth],
|
||||
[hero.balance, unModifiedHero.balance],
|
||||
[hero.secret, unModifiedHero.secret])"
|
||||
/>
|
||||
|
||||
<subscription-and-perks
|
||||
:hero="hero"
|
||||
:group-plans="groupPlans"
|
||||
:has-unsaved-changes="hasUnsavedChanges([hero.purchased.plan,
|
||||
unModifiedHero.purchased.plan])"
|
||||
/>
|
||||
|
||||
<cron-and-auth
|
||||
@@ -29,6 +36,7 @@
|
||||
<user-profile
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
:has-unsaved-changes="hasUnsavedChanges([hero.profile, unModifiedHero.profile])"
|
||||
/>
|
||||
|
||||
<party-and-quest
|
||||
@@ -47,6 +55,12 @@
|
||||
:preferences="hero.preferences"
|
||||
/>
|
||||
|
||||
<stats
|
||||
:hero="hero"
|
||||
:has-unsaved-changes="hasUnsavedChanges([hero.stats, unModifiedHero.stats])"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<items-owned
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
@@ -67,8 +81,18 @@
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<user-history
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<contributor-details
|
||||
:hero="hero"
|
||||
:hasUnsavedChanges="hasUnsavedChanges(
|
||||
[hero.contributor, unModifiedHero.contributor],
|
||||
[hero.permissions, unModifiedHero.permissions],
|
||||
[hero.secret, unModifiedHero.secret],
|
||||
)"
|
||||
:reset-counter="resetCounter"
|
||||
@clear-data="clearData"
|
||||
/>
|
||||
@@ -109,6 +133,7 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import isEqualWith from 'lodash/isEqualWith';
|
||||
import BasicDetails from './basicDetails';
|
||||
import ItemsOwned from './itemsOwned';
|
||||
import CronAndAuth from './cronAndAuth';
|
||||
@@ -121,6 +146,8 @@ import Transactions from './transactions';
|
||||
import SubscriptionAndPerks from './subscriptionAndPerks';
|
||||
import CustomizationsOwned from './customizationsOwned.vue';
|
||||
import Achievements from './achievements.vue';
|
||||
import UserHistory from './userHistory.vue';
|
||||
import Stats from './stats.vue';
|
||||
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
@@ -135,6 +162,8 @@ export default {
|
||||
PrivilegesAndGems,
|
||||
ContributorDetails,
|
||||
Transactions,
|
||||
UserHistory,
|
||||
Stats,
|
||||
SubscriptionAndPerks,
|
||||
UserProfile,
|
||||
Achievements,
|
||||
@@ -148,8 +177,10 @@ export default {
|
||||
return {
|
||||
userIdentifier: '',
|
||||
resetCounter: 0,
|
||||
unModifiedHero: {},
|
||||
hero: {},
|
||||
party: {},
|
||||
groupPlans: [],
|
||||
hasParty: false,
|
||||
partyNotExistError: false,
|
||||
adminHasPrivForParty: true,
|
||||
@@ -168,6 +199,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
clearData () {
|
||||
this.unModifiedHero = {};
|
||||
this.hero = {};
|
||||
},
|
||||
|
||||
@@ -176,6 +208,7 @@ export default {
|
||||
this.$emit('changeUserIdentifier', id); // change user identifier in Admin Panel's form
|
||||
|
||||
this.hero = await this.$store.dispatch('hall:getHero', { uuid: id });
|
||||
this.unModifiedHero = JSON.parse(JSON.stringify(this.hero));
|
||||
|
||||
if (!this.hero.flags) {
|
||||
this.hero.flags = {
|
||||
@@ -206,8 +239,38 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hero.purchased.plan.planId === 'group_plan_auto') {
|
||||
try {
|
||||
this.groupPlans = await this.$store.dispatch('hall:getHeroGroupPlans', { heroId: this.hero._id });
|
||||
} catch (e) {
|
||||
this.groupPlans = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.resetCounter += 1; // tell child components to reinstantiate from scratch
|
||||
},
|
||||
hasUnsavedChanges (...comparisons) {
|
||||
for (const index in comparisons) {
|
||||
if (index && comparisons[index]) {
|
||||
const objs = comparisons[index];
|
||||
const obj1 = objs[0];
|
||||
const obj2 = objs[1];
|
||||
if (!isEqualWith(obj1, obj2, (x, y) => {
|
||||
if (typeof x === 'object' && typeof y === 'object') {
|
||||
return undefined;
|
||||
}
|
||||
if (x === false && y === undefined) {
|
||||
// Special case for checkboxes
|
||||
return true;
|
||||
}
|
||||
return x == y; // eslint-disable-line eqeqeq
|
||||
})) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -269,16 +269,19 @@ export default {
|
||||
methods: {
|
||||
async saveItem (item) {
|
||||
// prepare the item's new value and path for being saved
|
||||
this.hero.itemPath = item.path;
|
||||
const toSave = {
|
||||
_id: this.hero._id,
|
||||
};
|
||||
toSave.itemPath = item.path;
|
||||
if (item.value === null) {
|
||||
this.hero.itemVal = 'null';
|
||||
toSave.itemVal = 'null';
|
||||
} else if (item.value === false) {
|
||||
this.hero.itemVal = 'false';
|
||||
toSave.itemVal = 'false';
|
||||
} else {
|
||||
this.hero.itemVal = item.value;
|
||||
toSave.itemVal = item.value;
|
||||
}
|
||||
|
||||
await this.saveHero({ hero: this.hero, msg: item.key });
|
||||
await this.saveHero({ hero: toSave, msg: item.key });
|
||||
item.neverOwned = false;
|
||||
item.modified = false;
|
||||
},
|
||||
|
||||
@@ -31,22 +31,41 @@
|
||||
v-html="questErrors"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Party:
|
||||
<span v-if="userHasParty">
|
||||
yes: party ID {{ groupPartyData._id }},
|
||||
member count {{ groupPartyData.memberCount }} (may be wrong)
|
||||
<br>
|
||||
<div v-if="userHasParty">
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Party ID
|
||||
</label>
|
||||
<strong class="col-sm-9 col-form-label">
|
||||
{{ groupPartyData._id }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Estimated Member Count
|
||||
</label>
|
||||
<strong class="col-sm-9 col-form-label">
|
||||
{{ groupPartyData.memberCount }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Leader
|
||||
</label>
|
||||
<strong class="col-sm-9 col-form-label">
|
||||
<span v-if="userIsPartyLeader">User is the party leader</span>
|
||||
<span v-else>Party leader is
|
||||
<router-link :to="{'name': 'userProfile', 'params': {'userId': groupPartyData.leader}}">
|
||||
{{ groupPartyData.leader }}
|
||||
</router-link>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>no</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
class="btn btn-danger"
|
||||
@click="removeFromParty()">Remove from Party</div>
|
||||
</div>
|
||||
<strong v-else>User is not in a party.</strong>
|
||||
<div class="subsection-start">
|
||||
<p v-html="questStatus"></p>
|
||||
</div>
|
||||
@@ -56,6 +75,7 @@
|
||||
|
||||
<script>
|
||||
import * as quests from '@/../../common/script/content/quests';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
function determineQuestStatus (self) {
|
||||
// Quest data is in the user doc and party doc. They can be out of sync.
|
||||
@@ -271,6 +291,7 @@ function resetData (self) {
|
||||
}
|
||||
|
||||
export default {
|
||||
mixins: [saveHero],
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
@@ -318,5 +339,14 @@ export default {
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
methods: {
|
||||
removeFromParty () {
|
||||
this.saveHero({
|
||||
hero: { _id: this.userId, removeFromParty: true },
|
||||
msg: 'Removed from party',
|
||||
reloadData: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<form @submit.prevent="saveHero({hero, msg: 'Privileges or Gems or Moderation Notes'})">
|
||||
<form @submit.prevent="saveHero({hero: {
|
||||
_id: hero._id,
|
||||
flags: hero.flags,
|
||||
balance: hero.balance,
|
||||
auth: hero.auth,
|
||||
secret: hero.secret,
|
||||
}, msg: 'Privileges or Gems or Moderation Notes'})">
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
@@ -8,6 +14,9 @@
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Privileges, Gem Balance
|
||||
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
@@ -117,13 +126,16 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -169,6 +181,10 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="form-group row">
|
||||
<label
|
||||
class="col-sm-3 col-form-label"
|
||||
:class="color"
|
||||
>{{ label }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
:value="value"
|
||||
class="form-control"
|
||||
type="number"
|
||||
:step="step"
|
||||
:max="max"
|
||||
:min="min"
|
||||
@input="$emit('input', parseInt($event.target.value, 10))"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.about-row {
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.red-label {
|
||||
color: $red_100;
|
||||
}
|
||||
.blue-label {
|
||||
color: $blue_100;
|
||||
}
|
||||
.purple-label {
|
||||
color: $purple_300;
|
||||
}
|
||||
.yellow-label {
|
||||
color: $yellow_50;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
model: {
|
||||
prop: 'value',
|
||||
event: 'input',
|
||||
},
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'text-label',
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
default: 'any',
|
||||
},
|
||||
min: {
|
||||
},
|
||||
max: {
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<form @submit.prevent="submitClicked()">
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
class="mb-0 mt-0"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Stats
|
||||
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-body"
|
||||
>
|
||||
<stats-row
|
||||
label="Health"
|
||||
color="red-label"
|
||||
:max="maxHealth"
|
||||
v-model="hero.stats.hp" />
|
||||
<stats-row
|
||||
label="Experience"
|
||||
color="yellow-label"
|
||||
min="0"
|
||||
:max="maxFieldHardCap"
|
||||
v-model="hero.stats.exp" />
|
||||
<stats-row
|
||||
label="Mana"
|
||||
color="blue-label"
|
||||
min="0"
|
||||
:max="maxFieldHardCap"
|
||||
v-model="hero.stats.mp" />
|
||||
<stats-row
|
||||
label="Level"
|
||||
step="1"
|
||||
min="0"
|
||||
:max="maxLevelHardCap"
|
||||
v-model="hero.stats.lvl" />
|
||||
<stats-row
|
||||
label="Gold"
|
||||
min="0"
|
||||
:max="maxFieldHardCap"
|
||||
v-model="hero.stats.gp" />
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">Selected Class</label>
|
||||
<div class="col-sm-9">
|
||||
<select
|
||||
id="selectedClass"
|
||||
v-model="hero.stats.class"
|
||||
class="form-control"
|
||||
:disabled="hero.stats.lvl < 10"
|
||||
>
|
||||
<option value="warrior">Warrior</option>
|
||||
<option value="wizard">Mage</option>
|
||||
<option value="healer">Healer</option>
|
||||
<option value="rogue">Rogue</option>
|
||||
</select>
|
||||
<small>
|
||||
When changing class, players usually need stat points deallocated as well.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Stat Points</h3>
|
||||
<stats-row
|
||||
label="Unallocated"
|
||||
min="0"
|
||||
step="1"
|
||||
:max="maxStatPoints"
|
||||
v-model="hero.stats.points" />
|
||||
<stats-row
|
||||
label="Strength"
|
||||
color="red-label"
|
||||
min="0"
|
||||
:max="maxStatPoints"
|
||||
step="1"
|
||||
v-model="hero.stats.str" />
|
||||
<stats-row
|
||||
label="Intelligence"
|
||||
color="blue-label"
|
||||
min="0"
|
||||
:max="maxStatPoints"
|
||||
step="1"
|
||||
v-model="hero.stats.int" />
|
||||
<stats-row
|
||||
label="Perception"
|
||||
color="purple-label"
|
||||
min="0"
|
||||
:max="maxStatPoints"
|
||||
step="1"
|
||||
v-model="hero.stats.per" />
|
||||
<stats-row
|
||||
label="Constitution"
|
||||
color="yellow-label"
|
||||
min="0"
|
||||
:max="maxStatPoints"
|
||||
step="1"
|
||||
v-model="hero.stats.con" />
|
||||
<div class="form-group row">
|
||||
<div class="offset-sm-3 col-sm-9">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning btn-sm"
|
||||
@click="deallocateStatPoints">
|
||||
Deallocate all stat points
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row" v-if="statPointsIncorrect">
|
||||
<div class="offset-sm-3 col-sm-9 text-danger">
|
||||
Error: Sum of stat points should equal the users level
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Buffs</h3>
|
||||
<stats-row
|
||||
label="Strength"
|
||||
color="red-label"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="hero.stats.buffs.str" />
|
||||
<stats-row
|
||||
label="Intelligence"
|
||||
color="blue-label"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="hero.stats.buffs.int" />
|
||||
<stats-row
|
||||
label="Perception"
|
||||
color="purple-label"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="hero.stats.buffs.per" />
|
||||
<stats-row
|
||||
label="Constitution"
|
||||
color="yellow-label"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="hero.stats.buffs.con" />
|
||||
<div class="form-group row">
|
||||
<div class="offset-sm-3 col-sm-9">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning btn-sm"
|
||||
@click="resetBuffs">
|
||||
Reset Buffs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.about-row {
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import {
|
||||
MAX_HEALTH,
|
||||
MAX_STAT_POINTS,
|
||||
MAX_LEVEL_HARD_CAP,
|
||||
MAX_FIELD_HARD_CAP,
|
||||
} from '@/../../common/script/constants';
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
import { mapState } from '@/libs/store';
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
import StatsRow from './stats-row';
|
||||
|
||||
function resetData (self) {
|
||||
self.expand = false;
|
||||
}
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
components: {
|
||||
StatsRow,
|
||||
},
|
||||
mixins: [
|
||||
userStateMixin,
|
||||
saveHero,
|
||||
],
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
statPointsIncorrect () {
|
||||
if (this.hero.stats.lvl >= 10) {
|
||||
return (parseInt(this.hero.stats.points, 10)
|
||||
+ parseInt(this.hero.stats.str, 10)
|
||||
+ parseInt(this.hero.stats.int, 10)
|
||||
+ parseInt(this.hero.stats.per, 10)
|
||||
+ parseInt(this.hero.stats.con, 10)
|
||||
) !== this.hero.stats.lvl;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
maxHealth: MAX_HEALTH,
|
||||
maxStatPoints: MAX_STAT_POINTS,
|
||||
maxLevelHardCap: MAX_LEVEL_HARD_CAP,
|
||||
maxFieldHardCap: MAX_FIELD_HARD_CAP,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
methods: {
|
||||
submitClicked () {
|
||||
if (this.statPointsIncorrect) {
|
||||
return;
|
||||
}
|
||||
this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
stats: this.hero.stats,
|
||||
},
|
||||
msg: 'Stats',
|
||||
});
|
||||
},
|
||||
resetBuffs () {
|
||||
this.hero.stats.buffs = {
|
||||
str: 0,
|
||||
int: 0,
|
||||
per: 0,
|
||||
con: 0,
|
||||
};
|
||||
},
|
||||
deallocateStatPoints () {
|
||||
this.hero.stats.points = this.hero.stats.lvl;
|
||||
this.hero.stats.str = 0;
|
||||
this.hero.stats.int = 0;
|
||||
this.hero.stats.per = 0;
|
||||
this.hero.stats.con = 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,30 +1,135 @@
|
||||
<template>
|
||||
<form @submit.prevent="saveHero({ hero, msg: 'Subscription Perks' })">
|
||||
<form
|
||||
@submit.prevent="saveHero({ hero: {
|
||||
_id: hero._id,
|
||||
purchased: hero.purchased
|
||||
}, msg: 'Subscription Perks' })"
|
||||
>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<div class="card-header"
|
||||
@click="expand = !expand">
|
||||
<h3
|
||||
class="mb-0 mt-0"
|
||||
:class="{ 'open': expand }"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Subscription, Monthly Perks
|
||||
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-body"
|
||||
>
|
||||
<div v-if="hero.purchased.plan.paymentMethod">
|
||||
Payment method:
|
||||
<strong>{{ hero.purchased.plan.paymentMethod }}</strong>
|
||||
<div
|
||||
class="form-group row"
|
||||
>
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Payment method:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input v-model="hero.purchased.plan.paymentMethod"
|
||||
class="form-control"
|
||||
type="text"
|
||||
v-if="!isRegularPaymentMethod"
|
||||
>
|
||||
<select
|
||||
v-else
|
||||
v-model="hero.purchased.plan.paymentMethod"
|
||||
class="form-control"
|
||||
type="text"
|
||||
>
|
||||
<option value="groupPlan">Group Plan</option>
|
||||
<option value="Stripe">Stripe</option>
|
||||
<option value="Apple">Apple</option>
|
||||
<option value="Google">Google</option>
|
||||
<option value="Amazon Payments">Amazon</option>
|
||||
<option value="PayPal">PayPal</option>
|
||||
<option value="Gift">Gift</option>
|
||||
<option value="">Clear out</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hero.purchased.plan.planId">
|
||||
Payment schedule ("basic-earned" is monthly):
|
||||
<strong>{{ hero.purchased.plan.planId }}</strong>
|
||||
<div
|
||||
class="form-group row"
|
||||
>
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Payment schedule:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input v-model="hero.purchased.plan.planId"
|
||||
class="form-control"
|
||||
type="text"
|
||||
v-if="!isRegularPlanId"
|
||||
>
|
||||
<select
|
||||
v-else
|
||||
v-model="hero.purchased.plan.planId"
|
||||
class="form-control"
|
||||
type="text"
|
||||
>
|
||||
<option value="basic_earned">Monthly recurring</option>
|
||||
<option value="basic_3mo">3 Months recurring</option>
|
||||
<option value="basic_6mo">6 Months recurring</option>
|
||||
<option value="basic_12mo">12 Months recurring</option>
|
||||
<option value="group_monthly">Group Plan (legacy)</option>
|
||||
<option value="group_plan_auto">Group Plan (auto)</option>
|
||||
<option value="">Clear out</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hero.purchased.plan.planId == 'group_plan_auto'">
|
||||
Group plan ID:
|
||||
<strong>{{ hero.purchased.plan.owner }}</strong>
|
||||
<div
|
||||
class="form-group row"
|
||||
>
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Customer ID:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
v-model="hero.purchased.plan.customerId"
|
||||
class="form-control"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row"
|
||||
v-if="hero.purchased.plan.planId === 'group_plan_auto'">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Group Plan Memberships:
|
||||
</label>
|
||||
<div class="col-sm-9 col-form-label">
|
||||
<loading-spinner
|
||||
v-if="!groupPlans"
|
||||
dark-color=true
|
||||
/>
|
||||
<b
|
||||
v-else-if="groupPlans.length === 0"
|
||||
class="text-danger col-form-label"
|
||||
>User is not part of an active group plan!</b>
|
||||
<div
|
||||
v-else
|
||||
v-for="group in groupPlans"
|
||||
:key="group._id"
|
||||
class="card mb-2">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{{ group.name }}
|
||||
<small class="float-right">{{ group._id }}</small>
|
||||
</h6>
|
||||
<p class="card-text">
|
||||
<strong>Leader: </strong>
|
||||
<a
|
||||
v-if="group.leader !== hero._id"
|
||||
@click="switchUser(group.leader)"
|
||||
>{{ group.leader }}</a>
|
||||
<strong v-else class="text-success">This user</strong>
|
||||
</p>
|
||||
<p class="card-text">
|
||||
<strong>Members: </strong> {{ group.memberCount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hero.purchased.plan.dateCreated"
|
||||
@@ -85,8 +190,18 @@
|
||||
<strong class="input-group-text">
|
||||
{{ dateFormat(hero.purchased.plan.dateTerminated) }}
|
||||
</strong>
|
||||
<a class="btn btn-danger"
|
||||
href="#"
|
||||
v-b-modal.sub_termination_modal
|
||||
v-if="!hero.purchased.plan.dateTerminated && hero.purchased.plan.planId">
|
||||
Terminate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="!hero.purchased.plan.dateTerminated
|
||||
&& hero.purchased.plan.planId" class="text-success">
|
||||
The subscription does not have a termination date and is active.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
@@ -101,6 +216,35 @@
|
||||
min="0"
|
||||
step="1"
|
||||
>
|
||||
<small class="text-secondary">
|
||||
Cumulative subscribed months across subscription periods.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Extra months:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<div class="input-group">
|
||||
<input
|
||||
v-model="hero.purchased.plan.extraMonths"
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
>
|
||||
<div class="input-group-append">
|
||||
<a class="btn btn-warning"
|
||||
@click="applyExtraMonths"
|
||||
v-if="hero.purchased.plan.dateTerminated && hero.purchased.plan.extraMonths > 0">
|
||||
Apply Credit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-secondary">
|
||||
Additional credit that is applied if a subscription is cancelled.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
@@ -174,10 +318,6 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hero.purchased.plan.extraMonths > 0">
|
||||
Additional credit (applied upon cancellation):
|
||||
<strong>{{ hero.purchased.plan.extraMonths }}</strong>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Mystery Items:
|
||||
@@ -199,18 +339,64 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row"
|
||||
v-if="!isConvertingToGroupPlan && hero.purchased.plan.planId !== 'group_plan_auto'">
|
||||
<div class="offset-sm-3 col-sm-9">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="beginGroupPlanConvert">
|
||||
Begin converting to group plan subscription
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row"
|
||||
v-if="isConvertingToGroupPlan">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Group Plan group ID:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
v-model="groupPlanID"
|
||||
class="form-control"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
@click="saveClicked"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<b-modal id="sub_termination_modal" title="Set Termination Date">
|
||||
<p>
|
||||
You can set the sub benefit termination date to today or to the last
|
||||
day of the current billing cycle. Any extra subscription credit will
|
||||
then be processed and automatically added onto the selected date.
|
||||
</p>
|
||||
<template #modal-footer>
|
||||
<div class="mt-3 btn btn-secondary" @click="$bvModal.hide('sub_termination_modal')">
|
||||
Close
|
||||
</div>
|
||||
<div class="mt-3 btn btn-danger" @click="terminateSubscription()">
|
||||
Set to Today
|
||||
</div>
|
||||
<div class="mt-3 btn btn-danger" @click="terminateSubscription(todayWithRemainingCycle)">
|
||||
Set to {{ todayWithRemainingCycle.utc().format('MM/DD/YYYY') }}
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -231,21 +417,38 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import isUUID from 'validator/es/lib/isUUID';
|
||||
import moment from 'moment';
|
||||
import { getPlanContext } from '@/../../common/script/cron';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
import subscriptionBlocks from '../../../../../common/script/content/subscriptionBlocks';
|
||||
import LoadingSpinner from '@/components/ui/loadingSpinner';
|
||||
|
||||
export default {
|
||||
mixins: [saveHero],
|
||||
components: {
|
||||
LoadingSpinner,
|
||||
},
|
||||
props: {
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
groupPlans: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
isConvertingToGroupPlan: false,
|
||||
groupPlanID: '',
|
||||
subscriptionBlocks,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -255,6 +458,30 @@ export default {
|
||||
if (!currentPlanContext.nextHourglassDate) return 'N/A';
|
||||
return currentPlanContext.nextHourglassDate.format('MMMM YYYY');
|
||||
},
|
||||
isRegularPlanId () {
|
||||
return this.subscriptionBlocks[this.hero.purchased.plan.planId] !== undefined;
|
||||
},
|
||||
isRegularPaymentMethod () {
|
||||
return [
|
||||
'groupPlan',
|
||||
'Group Plan',
|
||||
'Stripe',
|
||||
'Apple',
|
||||
'Google',
|
||||
'Amazon Payments',
|
||||
'PayPal',
|
||||
'Gift',
|
||||
].includes(this.hero.purchased.plan.paymentMethod);
|
||||
},
|
||||
todayWithRemainingCycle () {
|
||||
const now = moment();
|
||||
const monthCount = subscriptionBlocks[this.hero.purchased.plan.planId].months;
|
||||
const terminationDate = moment(this.hero.purchased.plan.dateCurrentTypeCreated || new Date());
|
||||
while (terminationDate.isBefore(now)) {
|
||||
terminationDate.add(monthCount, 'months');
|
||||
}
|
||||
return terminationDate;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
dateFormat (date) {
|
||||
@@ -263,6 +490,46 @@ export default {
|
||||
}
|
||||
return moment(date).format('YYYY/MM/DD');
|
||||
},
|
||||
terminateSubscription (terminationDate) {
|
||||
if (terminationDate) {
|
||||
this.hero.purchased.plan.dateTerminated = terminationDate.utc().format();
|
||||
} else {
|
||||
this.hero.purchased.plan.dateTerminated = moment(new Date()).utc().format();
|
||||
}
|
||||
this.applyExtraMonths();
|
||||
this.saveHero({ hero: this.hero, msg: 'Subscription Termination', reloadData: true });
|
||||
},
|
||||
applyExtraMonths () {
|
||||
if (this.hero.purchased.plan.extraMonths > 0 || this.hero.purchased.plan.extraMonths !== '0') {
|
||||
const date = moment(this.hero.purchased.plan.dateTerminated || new Date());
|
||||
const extraMonths = Math.max(this.hero.purchased.plan.extraMonths, 0);
|
||||
const extraDays = Math.ceil(30.5 * extraMonths);
|
||||
this.hero.purchased.plan.dateTerminated = date.add(extraDays, 'days').utc().format();
|
||||
this.hero.purchased.plan.extraMonths = 0;
|
||||
}
|
||||
},
|
||||
beginGroupPlanConvert () {
|
||||
this.isConvertingToGroupPlan = true;
|
||||
this.hero.purchased.plan.owner = '';
|
||||
},
|
||||
saveClicked (e) {
|
||||
e.preventDefault();
|
||||
if (this.isConvertingToGroupPlan) {
|
||||
if (!isUUID(this.groupPlanID)) {
|
||||
alert('Invalid group ID');
|
||||
return;
|
||||
}
|
||||
this.hero.purchased.plan.convertToGroupPlan = this.groupPlanID;
|
||||
this.saveHero({ hero: this.hero, msg: 'Group Plan Subscription', reloadData: true });
|
||||
} else {
|
||||
this.saveHero({ hero: this.hero, msg: 'Subscription Perks', reloadData: true });
|
||||
}
|
||||
},
|
||||
switchUser (id) {
|
||||
if (window.confirm('Switch to this user?')) {
|
||||
this.$emit('changeUserIdentifier', id);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
class="mb-0 mt-0"
|
||||
:class="{'open': expand}"
|
||||
@click="toggleHistoryOpen"
|
||||
>
|
||||
User History
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-body"
|
||||
>
|
||||
<div>
|
||||
<div class="clearfix">
|
||||
<div class="mb-4 float-left">
|
||||
<button
|
||||
class="page-header btn-flat tab-button textCondensed"
|
||||
:class="{'active': selectedTab === 'armoire'}"
|
||||
@click="selectTab('armoire')"
|
||||
>
|
||||
Armoire
|
||||
</button>
|
||||
<button
|
||||
class="page-header btn-flat tab-button textCondensed"
|
||||
:class="{'active': selectedTab === 'questInvites'}"
|
||||
@click="selectTab('questInvites')"
|
||||
>
|
||||
Quest Invitations
|
||||
</button>
|
||||
<button
|
||||
class="page-header btn-flat tab-button textCondensed"
|
||||
:class="{'active': selectedTab === 'cron'}"
|
||||
@click="selectTab('cron')"
|
||||
>
|
||||
Cron
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div
|
||||
v-if="selectedTab === 'armoire'"
|
||||
class="col-12"
|
||||
>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th
|
||||
v-once
|
||||
>
|
||||
{{ $t('timestamp') }}
|
||||
</th>
|
||||
<th v-once>
|
||||
Client
|
||||
</th>
|
||||
<th
|
||||
v-once
|
||||
>
|
||||
Received
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="entry in armoire"
|
||||
:key="entry.timestamp"
|
||||
>
|
||||
<td>
|
||||
<span
|
||||
v-b-tooltip.hover="entry.timestamp"
|
||||
>{{ entry.timestamp | timeAgo }}</span>
|
||||
</td>
|
||||
<td>{{ entry.client }}</td>
|
||||
<td>{{ entry.reward }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTab === 'questInvites'"
|
||||
class="col-12"
|
||||
>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th
|
||||
v-once
|
||||
>
|
||||
{{ $t('timestamp') }}
|
||||
</th>
|
||||
<th v-once>
|
||||
Client
|
||||
</th>
|
||||
<th v-once>
|
||||
Quest Key
|
||||
</th>
|
||||
<th v-once>
|
||||
Response
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="entry in questInviteResponses"
|
||||
:key="entry.timestamp"
|
||||
>
|
||||
<td>
|
||||
<span
|
||||
v-b-tooltip.hover="entry.timestamp"
|
||||
>{{ entry.timestamp | timeAgo }}</span>
|
||||
</td>
|
||||
<td>{{ entry.client }}</td>
|
||||
<td>{{ entry.quest }}</td>
|
||||
<td>{{ questInviteResponseText(entry.response) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTab === 'cron'"
|
||||
class="col-12"
|
||||
>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th
|
||||
v-once
|
||||
>
|
||||
{{ $t('timestamp') }}
|
||||
</th>
|
||||
<th v-once>
|
||||
Client
|
||||
</th>
|
||||
<th v-once>
|
||||
Checkin Count
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="entry in cron"
|
||||
:key="entry.timestamp"
|
||||
>
|
||||
<td>
|
||||
<span
|
||||
v-b-tooltip.hover="entry.timestamp"
|
||||
>{{ entry.timestamp | timeAgo }}</span>
|
||||
</td>
|
||||
<td>{{ entry.client }}</td>
|
||||
<td>{{ entry.checkinCount }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.page-header.btn-flat {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
height: 2rem;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
font-stretch: condensed;
|
||||
line-height: 1.33;
|
||||
letter-spacing: normal;
|
||||
color: $gray-10;
|
||||
|
||||
margin-right: 1.125rem;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-bottom: 2.5rem;
|
||||
|
||||
&.active, &:hover {
|
||||
color: $purple-300;
|
||||
box-shadow: 0px -0.25rem 0px $purple-300 inset;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
export default {
|
||||
filters: {
|
||||
timeAgo (value) {
|
||||
return moment(value).fromNow();
|
||||
},
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: {
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
selectedTab: 'armoire',
|
||||
armoire: [],
|
||||
questInviteResponses: [],
|
||||
cron: [],
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
if (this.expand) {
|
||||
this.retrieveUserHistory();
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectTab (type) {
|
||||
this.selectedTab = type;
|
||||
},
|
||||
async toggleHistoryOpen () {
|
||||
this.expand = !this.expand;
|
||||
if (this.expand) {
|
||||
this.retrieveUserHistory();
|
||||
}
|
||||
},
|
||||
async retrieveUserHistory () {
|
||||
const history = await this.$store.dispatch('adminPanel:getUserHistory', { userIdentifier: this.hero._id });
|
||||
this.armoire = history.armoire;
|
||||
this.questInviteResponses = history.questInviteResponses;
|
||||
this.cron = history.cron;
|
||||
},
|
||||
questInviteResponseText (response) {
|
||||
if (response === 'accept') {
|
||||
return 'Accepted';
|
||||
}
|
||||
if (response === 'reject') {
|
||||
return 'Rejected';
|
||||
}
|
||||
if (response === 'leave') {
|
||||
return 'Left active quest';
|
||||
}
|
||||
if (response === 'invite') {
|
||||
return 'Accepted as owner';
|
||||
}
|
||||
if (response === 'abort') {
|
||||
return 'Aborted by owner';
|
||||
}
|
||||
if (response === 'abortByLeader') {
|
||||
return 'Aborted by party leader';
|
||||
}
|
||||
if (response === 'cancel') {
|
||||
return 'Cancelled before start';
|
||||
}
|
||||
if (response === 'cancelByLeader') {
|
||||
return 'Cancelled before start by party leader';
|
||||
}
|
||||
return response;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<form @submit.prevent="saveHero({hero, msg: 'Users Profile'})">
|
||||
<form
|
||||
@submit.prevent="saveHero({hero: {
|
||||
_id: hero._id,
|
||||
profile: hero.profile
|
||||
}, msg: 'Users Profile'})"
|
||||
>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
@@ -8,6 +13,9 @@
|
||||
@click="expand = !expand"
|
||||
>
|
||||
User Profile
|
||||
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
@@ -51,13 +59,16 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -101,6 +112,10 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -944,24 +944,28 @@ export default {
|
||||
},
|
||||
async jumpTime (amount) {
|
||||
const response = await axios.post('/api/v4/debug/jump-time', { offsetDays: amount });
|
||||
if (amount > 0) {
|
||||
Vue.config.clock.jump(amount * 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
Vue.config.clock.setSystemTime(moment().add(amount, 'days').toDate());
|
||||
}
|
||||
this.lastTimeJump = response.data.data.time;
|
||||
this.triggerGetWorldState(true);
|
||||
setTimeout(() => {
|
||||
if (amount > 0) {
|
||||
Vue.config.clock.jump(amount * 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
Vue.config.clock.setSystemTime(moment().add(amount, 'days').toDate());
|
||||
}
|
||||
this.lastTimeJump = response.data.data.time;
|
||||
this.triggerGetWorldState(true);
|
||||
}, 1000);
|
||||
},
|
||||
async resetTime () {
|
||||
const response = await axios.post('/api/v4/debug/jump-time', { reset: true });
|
||||
const time = new Date(response.data.data.time);
|
||||
Vue.config.clock.restore();
|
||||
Vue.config.clock = sinon.useFakeTimers({
|
||||
now: time,
|
||||
shouldAdvanceTime: true,
|
||||
});
|
||||
this.lastTimeJump = response.data.data.time;
|
||||
this.triggerGetWorldState(true);
|
||||
setTimeout(() => {
|
||||
Vue.config.clock.restore();
|
||||
Vue.config.clock = sinon.useFakeTimers({
|
||||
now: time,
|
||||
shouldAdvanceTime: true,
|
||||
});
|
||||
this.lastTimeJump = response.data.data.time;
|
||||
this.triggerGetWorldState(true);
|
||||
}, 1000);
|
||||
},
|
||||
addExp () {
|
||||
// @TODO: Name these variables better
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="member.preferences"
|
||||
class="avatar"
|
||||
:style="{width, height, paddingTop}"
|
||||
:class="backgroundClass"
|
||||
:class="topLevelClassList"
|
||||
@click.prevent="castEnd()"
|
||||
>
|
||||
<div
|
||||
@@ -55,7 +55,11 @@
|
||||
<span :class="[getGearClass('eyewear'), specialMountClass]"></span>
|
||||
<span :class="[getGearClass('head'), specialMountClass]"></span>
|
||||
<span :class="[getGearClass('headAccessory'), specialMountClass]"></span>
|
||||
<span :class="['hair_flower_' + member.preferences.hair.flower, specialMountClass]"></span>
|
||||
<span
|
||||
:class="[
|
||||
'hair_flower_' + member.preferences.hair.flower, specialMountClass
|
||||
]"
|
||||
></span>
|
||||
<span
|
||||
v-if="!hideGear('shield')"
|
||||
:class="[getGearClass('shield'), specialMountClass]"
|
||||
@@ -63,6 +67,7 @@
|
||||
<span
|
||||
v-if="!hideGear('weapon')"
|
||||
:class="[getGearClass('weapon'), specialMountClass]"
|
||||
class="weapon"
|
||||
></span>
|
||||
</template>
|
||||
<!-- Resting-->
|
||||
@@ -96,15 +101,23 @@
|
||||
|
||||
.avatar {
|
||||
width: 141px;
|
||||
height: 147px;
|
||||
image-rendering: pixelated;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&.centered-avatar {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// resetting the additional padding
|
||||
margin-bottom: -0.5rem !important;
|
||||
}
|
||||
|
||||
.character-sprites {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.character-sprites span {
|
||||
@@ -123,22 +136,49 @@
|
||||
.invert {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
.debug {
|
||||
border: 1px solid red;
|
||||
|
||||
.character-sprites {
|
||||
border: 1px solid blue;
|
||||
}
|
||||
|
||||
.weapon {
|
||||
border: 1px solid green;
|
||||
}
|
||||
|
||||
span {
|
||||
border: 1px solid yellow;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import some from 'lodash/some';
|
||||
import moment from 'moment';
|
||||
import { mapState } from '@/libs/store';
|
||||
import foolPet from '../mixins/foolPet';
|
||||
|
||||
import ClassBadge from '@/components/members/classBadge';
|
||||
|
||||
/**
|
||||
* TODO replace avatarOnly with multiple options like
|
||||
* - showMount
|
||||
* - showPet
|
||||
* - showBackground
|
||||
* - showWeapons
|
||||
*/
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ClassBadge,
|
||||
},
|
||||
mixins: [foolPet],
|
||||
props: {
|
||||
debugMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -156,14 +196,21 @@ export default {
|
||||
},
|
||||
overrideAvatarGear: {
|
||||
type: Object,
|
||||
default (data) {
|
||||
return data;
|
||||
},
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 140,
|
||||
type: String,
|
||||
default: '141px',
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 147,
|
||||
type: String,
|
||||
default: '147px',
|
||||
},
|
||||
centerAvatar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
spritesMargin: {
|
||||
type: String,
|
||||
@@ -171,11 +218,16 @@ export default {
|
||||
},
|
||||
overrideTopPadding: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
showVisualBuffs: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showWeapon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
@@ -204,6 +256,19 @@ export default {
|
||||
|
||||
return val;
|
||||
},
|
||||
topLevelClassList () {
|
||||
const classes = [this.backgroundClass];
|
||||
|
||||
if (this.debugMode) {
|
||||
classes.push('debug');
|
||||
}
|
||||
|
||||
if (this.centerAvatar) {
|
||||
classes.push('centered-avatar');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
},
|
||||
backgroundClass () {
|
||||
if (this.member) {
|
||||
const { background } = this.member.preferences;
|
||||
@@ -256,11 +321,10 @@ export default {
|
||||
return null;
|
||||
},
|
||||
petClass () {
|
||||
if (some(
|
||||
this.currentEventList,
|
||||
event => moment().isBetween(event.start, event.end) && event.aprilFools && event.aprilFools === 'Fungi',
|
||||
)) {
|
||||
return this.foolPet(this.member.items.currentPet);
|
||||
const foolEvent = this.currentEventList?.find(event => moment()
|
||||
.isBetween(event.start, event.end) && event.aprilFools);
|
||||
if (foolEvent) {
|
||||
return this.foolPet(this.member.items.currentPet, foolEvent.aprilFools);
|
||||
}
|
||||
if (this.member?.items.currentPet) return `Pet-${this.member.items.currentPet}`;
|
||||
return '';
|
||||
@@ -290,6 +354,10 @@ export default {
|
||||
},
|
||||
hideGear (gearType) {
|
||||
if (!this.member) return true;
|
||||
if (!this.showWeapon) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (gearType === 'weapon') {
|
||||
const equippedWeapon = this.member.items.gear[this.costumeClass][gearType];
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
>
|
||||
<div
|
||||
v-for="option in items"
|
||||
:key="option.key"
|
||||
:id="option.imageName"
|
||||
:key="option.key"
|
||||
class="outer-option-background"
|
||||
:class="{
|
||||
premium: Boolean(option.gem),
|
||||
@@ -28,15 +28,14 @@
|
||||
v-if="!option.none"
|
||||
class="sprite"
|
||||
:prefix="option.isGear ? 'shop' : 'icon'"
|
||||
:imageName="option.imageName"
|
||||
:image-name="option.imageName"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="redline-outer"
|
||||
>
|
||||
<div class="redline"></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="redline-outer"
|
||||
>
|
||||
<div class="redline"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="isUserMentioned"
|
||||
class="mentioned-icon"
|
||||
></div>
|
||||
<div
|
||||
v-if="hasPermission(user, 'moderator') && msg.flagCount"
|
||||
class="message-hidden"
|
||||
>
|
||||
{{ flagCountDescription }}
|
||||
</div>
|
||||
<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
|
||||
ref="markdownContainer"
|
||||
class="text markdown"
|
||||
dir="auto"
|
||||
v-html="parseMarkdown(msg.text)"
|
||||
></div>
|
||||
<hr>
|
||||
<div
|
||||
v-if="msg.id"
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
class="action d-flex align-items-center"
|
||||
@click="copyAsTodo(msg)"
|
||||
>
|
||||
<div
|
||||
class="svg-icon"
|
||||
v-html="icons.copy"
|
||||
></div>
|
||||
<div>{{ $t('copyAsTodo') }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
|
||||
&& (!isMessageReported || hasPermission(user, 'moderator'))"
|
||||
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
|
||||
v-if="msg.uuid === user._id || hasPermission(user, 'moderator')"
|
||||
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
|
||||
v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}"
|
||||
class="ml-auto d-flex"
|
||||
>
|
||||
<div
|
||||
v-if="likeCount > 0"
|
||||
class="action d-flex align-items-center mr-0"
|
||||
:class="{activeLike: msg.likes[user._id]}"
|
||||
@click="like()"
|
||||
>
|
||||
<div
|
||||
class="svg-icon"
|
||||
:title="$t('liked')"
|
||||
v-html="icons.liked"
|
||||
></div>
|
||||
+{{ likeCount }}
|
||||
</div>
|
||||
<div
|
||||
v-if="likeCount === 0"
|
||||
class="action d-flex align-items-center mr-0"
|
||||
:class="{activeLike: msg.likes[user._id]}"
|
||||
@click="like()"
|
||||
>
|
||||
<div
|
||||
class="svg-icon"
|
||||
:title="$t('like')"
|
||||
v-html="icons.like"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!msg.likes[user._id]">{{ $t('like') }}</span>
|
||||
</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';
|
||||
|
||||
.mentioned-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: #bda8ff;
|
||||
box-shadow: 0 1px 1px 0 rgba(26, 24, 29, 0.12);
|
||||
position: absolute;
|
||||
right: -.5em;
|
||||
top: -.5em;
|
||||
}
|
||||
|
||||
.message-hidden {
|
||||
margin-left: 1.5em;
|
||||
margin-top: 1em;
|
||||
color: red;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #878190;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
color: #4e4a57;
|
||||
text-align: initial;
|
||||
min-height: 0rem;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-block;
|
||||
color: #878190;
|
||||
margin-right: 1em;
|
||||
font-size: 12px;
|
||||
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
color: #A5A1AC;
|
||||
margin-right: .2em;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.activeLike {
|
||||
color: $purple-300;
|
||||
|
||||
.svg-icon {
|
||||
color: $purple-400;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import escapeRegExp from 'lodash/escapeRegExp';
|
||||
|
||||
import { CHAT_FLAG_LIMIT_FOR_HIDING, CHAT_FLAG_FROM_SHADOW_MUTE } from '@/../../common/script/constants';
|
||||
import renderWithMentions from '@/libs/renderWithMentions';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
import userLink from '../userLink';
|
||||
|
||||
import deleteIcon from '@/assets/svg/delete.svg';
|
||||
import copyIcon from '@/assets/svg/copy.svg';
|
||||
import likeIcon from '@/assets/svg/like.svg';
|
||||
import likedIcon from '@/assets/svg/liked.svg';
|
||||
import reportIcon from '@/assets/svg/report.svg';
|
||||
|
||||
export default {
|
||||
components: { userLink },
|
||||
filters: {
|
||||
timeAgo (value) {
|
||||
return moment(value).fromNow();
|
||||
},
|
||||
date (value) {
|
||||
// @TODO: Vue doesn't support this so we cant user preference
|
||||
return moment(value).toDate().toString();
|
||||
},
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: {
|
||||
msg: {},
|
||||
groupId: {},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
like: likeIcon,
|
||||
copy: copyIcon,
|
||||
report: reportIcon,
|
||||
delete: deleteIcon,
|
||||
liked: likedIcon,
|
||||
}),
|
||||
reported: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isUserMentioned () {
|
||||
const message = this.msg;
|
||||
|
||||
if (message.highlight) return true;
|
||||
|
||||
const { user } = this;
|
||||
const displayName = user.profile.name;
|
||||
const { username } = user.auth.local;
|
||||
const pattern = `@(${escapeRegExp(displayName)}|${escapeRegExp(username)})(\\b)`;
|
||||
message.highlight = new RegExp(pattern, 'i').test(message.text);
|
||||
|
||||
return message.highlight;
|
||||
},
|
||||
likeCount () {
|
||||
const message = this.msg;
|
||||
if (!message.likes) return 0;
|
||||
|
||||
let likeCount = 0;
|
||||
for (const key of Object.keys(message.likes)) {
|
||||
const like = message.likes[key];
|
||||
if (like) likeCount += 1;
|
||||
}
|
||||
return likeCount;
|
||||
},
|
||||
isMessageReported () {
|
||||
return (this.msg.flags && this.msg.flags[this.user.id]) || this.reported;
|
||||
},
|
||||
flagCountDescription () {
|
||||
if (!this.msg.flagCount) return '';
|
||||
if (this.msg.flagCount < CHAT_FLAG_LIMIT_FOR_HIDING) return 'Message flagged once, not hidden';
|
||||
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');
|
||||
for (let i = 0; i < links.length; i += 1) {
|
||||
let link = links[i].pathname;
|
||||
|
||||
// Internet Explorer does not provide the leading slash character in the pathname
|
||||
link = link.charAt(0) === '/' ? link : `/${link}`;
|
||||
|
||||
if (link.startsWith('/profile/')) {
|
||||
links[i].onclick = ev => {
|
||||
ev.preventDefault();
|
||||
this.$router.push({ path: link });
|
||||
};
|
||||
}
|
||||
}
|
||||
this.CHAT_FLAG_LIMIT_FOR_HIDING = CHAT_FLAG_LIMIT_FOR_HIDING;
|
||||
this.CHAT_FLAG_FROM_SHADOW_MUTE = CHAT_FLAG_FROM_SHADOW_MUTE;
|
||||
this.$emit('chat-card-mounted', this.msg.id);
|
||||
},
|
||||
methods: {
|
||||
async like () {
|
||||
const message = cloneDeep(this.msg);
|
||||
|
||||
await this.$store.dispatch('chat:like', {
|
||||
groupId: this.groupId,
|
||||
chatId: message.id,
|
||||
});
|
||||
|
||||
message.likes[this.user._id] = !message.likes[this.user._id];
|
||||
|
||||
this.$emit('message-liked', message);
|
||||
this.$root.$emit('bv::hide::tooltip');
|
||||
},
|
||||
likeTooltip (likedStatus) {
|
||||
if (!likedStatus) return this.$t('like');
|
||||
return null;
|
||||
},
|
||||
copyAsTodo (message) {
|
||||
this.$root.$emit('habitica::copy-as-todo', message);
|
||||
},
|
||||
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: this.groupId || 'privateMessage',
|
||||
});
|
||||
},
|
||||
async remove () {
|
||||
if (!window.confirm(this.$t('areYouSureDeleteMessage'))) return; // eslint-disable-line no-alert
|
||||
|
||||
const message = this.msg;
|
||||
this.$emit('message-removed', message);
|
||||
|
||||
await this.$store.dispatch('chat:deleteChat', {
|
||||
groupId: this.groupId,
|
||||
chatId: message.id,
|
||||
});
|
||||
},
|
||||
parseMarkdown (text) {
|
||||
return renderWithMentions(text, this.user);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -3,15 +3,6 @@
|
||||
ref="container"
|
||||
class="container-fluid"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<copy-as-todo-modal
|
||||
:group-type="groupType"
|
||||
:group-name="groupName"
|
||||
:group-id="groupId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row loadmore">
|
||||
<div v-if="canLoadMore">
|
||||
<div class="loadmore-divider"></div>
|
||||
@@ -33,11 +24,14 @@
|
||||
<div
|
||||
v-for="msg in messages.filter(m => chat && canViewFlag(m))"
|
||||
:key="msg.id"
|
||||
class="message-row"
|
||||
:class="{ 'margin-right': user._id !== msg.uuid}"
|
||||
>
|
||||
<div class="d-flex">
|
||||
<avatar
|
||||
v-if="user._id !== msg.uuid && msg.uuid !== 'system'"
|
||||
class="avatar-left"
|
||||
:height="null"
|
||||
:class="{ invisible: avatarUnavailable(msg) }"
|
||||
:member="msg.userStyles || cachedProfileData[msg.uuid] || {}"
|
||||
:avatar-only="true"
|
||||
@@ -45,20 +39,19 @@
|
||||
:override-top-padding="'14px'"
|
||||
@click.native="showMemberModal(msg.uuid)"
|
||||
/>
|
||||
<div class="card">
|
||||
<chat-card
|
||||
:msg="msg"
|
||||
:group-id="groupId"
|
||||
@message-liked="messageLiked"
|
||||
@message-removed="messageRemoved"
|
||||
@show-member-modal="showMemberModal"
|
||||
@chat-card-mounted="itemWasMounted"
|
||||
/>
|
||||
</div>
|
||||
<message-card
|
||||
:msg="msg"
|
||||
:group-id="groupId"
|
||||
:user-sent-message="user._id === msg.uuid"
|
||||
@message-liked="messageLiked"
|
||||
@message-removed="messageRemoved"
|
||||
@message-card-mounted="itemWasMounted"
|
||||
/>
|
||||
<avatar
|
||||
v-if="user._id === msg.uuid"
|
||||
:class="{ invisible: avatarUnavailable(msg) }"
|
||||
:member="msg.userStyles || cachedProfileData[msg.uuid] || {}"
|
||||
:height="null"
|
||||
:avatar-only="true"
|
||||
:hide-class-badge="true"
|
||||
:override-top-padding="'14px'"
|
||||
@@ -105,11 +98,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-left {
|
||||
margin-left: -1.5rem;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.hr {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
@@ -137,11 +125,27 @@
|
||||
margin-bottom: .5em;
|
||||
padding: 0rem;
|
||||
width: 90%;
|
||||
|
||||
&.system-message {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.message-scroll .d-flex {
|
||||
min-width: 1px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
margin-left: 12px;
|
||||
margin-right: 0;
|
||||
margin-bottom: 1.2rem;
|
||||
|
||||
&:not(.margin-right) {
|
||||
.d-flex {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -152,13 +156,13 @@ import findIndex from 'lodash/findIndex';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
import Avatar from '../avatar';
|
||||
import copyAsTodoModal from './copyAsTodoModal';
|
||||
import chatCard from './chatCard';
|
||||
import MessageCard from '@/components/messages/messageCard.vue';
|
||||
|
||||
// TODO merge chatMessages.vue (party message list) with messageList.vue (private message list)
|
||||
|
||||
export default {
|
||||
components: {
|
||||
copyAsTodoModal,
|
||||
chatCard,
|
||||
MessageCard,
|
||||
Avatar,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<b-modal
|
||||
id="copyAsTodo"
|
||||
:title="$t('copyMessageAsToDo')"
|
||||
:hide-footer="true"
|
||||
size="md"
|
||||
>
|
||||
<div class="form-group">
|
||||
<input
|
||||
v-model="task.text"
|
||||
class="form-control"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<textarea
|
||||
v-model="task.notes"
|
||||
class="form-control"
|
||||
rows="5"
|
||||
focus-element="true"
|
||||
></textarea>
|
||||
</div>
|
||||
<hr>
|
||||
<task
|
||||
v-if="task._id"
|
||||
:is-user="isUser"
|
||||
:task="task"
|
||||
/>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
@click="close()"
|
||||
>
|
||||
{{ $t('close') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="saveTodo()"
|
||||
>
|
||||
{{ $t('submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import taskDefaults from '@/../../common/script/libs/taskDefaults';
|
||||
import { mapActions } from '@/libs/store';
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import notificationsMixin from '@/mixins/notifications';
|
||||
import Task from '@/components/tasks/task';
|
||||
|
||||
const baseUrl = 'https://habitica.com';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
components: {
|
||||
Task,
|
||||
},
|
||||
mixins: [notificationsMixin],
|
||||
props: ['copyingMessage', 'groupType', 'groupName', 'groupId'],
|
||||
data () {
|
||||
return {
|
||||
isUser: true,
|
||||
task: {},
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
this.$root.$on('habitica::copy-as-todo', message => {
|
||||
const notes = `${message.user || 'system message'}${message.user ? ' wrote' : ''} in [${this.groupName}](${this.groupPath()})`;
|
||||
const newTask = {
|
||||
text: message.text,
|
||||
type: 'todo',
|
||||
notes,
|
||||
};
|
||||
this.task = taskDefaults(newTask, this.$store.state.user.data);
|
||||
this.$root.$emit('bv::show::modal', 'copyAsTodo');
|
||||
});
|
||||
},
|
||||
beforeDestroy () {
|
||||
this.$root.$off('habitica::copy-as-todo');
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
createTask: 'tasks:create',
|
||||
}),
|
||||
groupPath () {
|
||||
if (this.groupType === 'party') {
|
||||
return `${baseUrl}/party`;
|
||||
}
|
||||
return `${baseUrl}/groups/guild/${this.groupId}`;
|
||||
},
|
||||
close () {
|
||||
this.$root.$emit('bv::hide::modal', 'copyAsTodo');
|
||||
},
|
||||
saveTodo () {
|
||||
this.createTask(this.task);
|
||||
this.text(this.$t('messageAddedAsToDo'));
|
||||
this.$root.$emit('bv::hide::modal', 'copyAsTodo');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -22,13 +22,13 @@
|
||||
:placeholder="placeholder"
|
||||
:class="{'user-entry': newMessage}"
|
||||
:maxlength="MAX_MESSAGE_LENGTH"
|
||||
@keydown="updateCarretPosition"
|
||||
@keydown="autoCompleteMixinUpdateCarretPosition"
|
||||
@keyup.ctrl.enter="sendMessageShortcut()"
|
||||
@keydown.tab="handleTab($event)"
|
||||
@keydown.up="selectPreviousAutocomplete($event)"
|
||||
@keydown.down="selectNextAutocomplete($event)"
|
||||
@keypress.enter="selectAutocomplete($event)"
|
||||
@keydown.esc="handleEscape($event)"
|
||||
@keydown.tab="autoCompleteMixinHandleTab($event)"
|
||||
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
|
||||
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
|
||||
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
|
||||
@keydown.esc="autoCompleteMixinHandleEscape($event)"
|
||||
@paste="disableMessageSendShortcut()"
|
||||
></textarea>
|
||||
<span>{{ currentLength }} / {{ MAX_MESSAGE_LENGTH }}</span>
|
||||
@@ -36,8 +36,8 @@
|
||||
ref="autocomplete"
|
||||
:text="newMessage"
|
||||
:textbox="textbox"
|
||||
:coords="coords"
|
||||
:caret-position="caretPosition"
|
||||
:coords="mixinData.autoComplete.coords"
|
||||
:caret-position="mixinData.autoComplete.caretPosition"
|
||||
:chat="group.chat"
|
||||
@select="selectedAutocomplete"
|
||||
/>
|
||||
@@ -74,7 +74,7 @@
|
||||
<slot name="additionRow"></slot>
|
||||
<div class="row">
|
||||
<div class="hr col-12"></div>
|
||||
<chat-message
|
||||
<chat-messages
|
||||
:chat.sync="group.chat"
|
||||
:group-type="group.type"
|
||||
:group-id="group._id"
|
||||
@@ -86,16 +86,15 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants';
|
||||
import externalLinks from '../../mixins/externalLinks';
|
||||
|
||||
import autocomplete from '../chat/autoComplete';
|
||||
import communityGuidelines from './communityGuidelines';
|
||||
import chatMessage from '../chat/chatMessages';
|
||||
import chatMessages from '../chat/chatMessages';
|
||||
import { mapState } from '@/libs/store';
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
@@ -104,23 +103,18 @@ export default {
|
||||
components: {
|
||||
autocomplete,
|
||||
communityGuidelines,
|
||||
chatMessage,
|
||||
chatMessages,
|
||||
},
|
||||
mixins: [externalLinks],
|
||||
mixins: [externalLinks, autoCompleteHelperMixin],
|
||||
props: ['label', 'group', 'placeholder'],
|
||||
data () {
|
||||
return {
|
||||
newMessage: '',
|
||||
sending: false,
|
||||
caretPosition: 0,
|
||||
chat: {
|
||||
submitDisable: false,
|
||||
submitTimeout: null,
|
||||
},
|
||||
coords: {
|
||||
TOP: 0,
|
||||
LEFT: 0,
|
||||
},
|
||||
textbox: null,
|
||||
MAX_MESSAGE_LENGTH: MAX_MESSAGE_LENGTH.toString(),
|
||||
};
|
||||
@@ -142,35 +136,6 @@ export default {
|
||||
this.handleExternalLinks();
|
||||
},
|
||||
methods: {
|
||||
// https://medium.com/@_jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a
|
||||
getCoord (e, text) {
|
||||
this.caretPosition = text.selectionEnd;
|
||||
const div = document.createElement('div');
|
||||
const span = document.createElement('span');
|
||||
const copyStyle = getComputedStyle(text);
|
||||
|
||||
[].forEach.call(copyStyle, prop => {
|
||||
div.style[prop] = copyStyle[prop];
|
||||
});
|
||||
|
||||
div.style.position = 'absolute';
|
||||
document.body.appendChild(div);
|
||||
div.textContent = text.value.substr(0, this.caretPosition);
|
||||
span.textContent = text.value.substr(this.caretPosition) || '.';
|
||||
div.appendChild(span);
|
||||
this.coords = {
|
||||
TOP: span.offsetTop,
|
||||
LEFT: span.offsetLeft,
|
||||
};
|
||||
document.body.removeChild(div);
|
||||
},
|
||||
updateCarretPosition: debounce(function updateCarretPosition (eventUpdate) {
|
||||
this._updateCarretPosition(eventUpdate);
|
||||
}, 250),
|
||||
_updateCarretPosition (eventUpdate) {
|
||||
const text = eventUpdate.target;
|
||||
this.getCoord(eventUpdate, text);
|
||||
},
|
||||
async sendMessageShortcut () {
|
||||
// If the user recently pasted in the text field, don't submit
|
||||
if (!this.chat.submitDisable) {
|
||||
@@ -221,50 +186,6 @@ export default {
|
||||
}, 500);
|
||||
},
|
||||
|
||||
handleTab (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
this.$refs.autocomplete.selectPrevious();
|
||||
} else {
|
||||
this.$refs.autocomplete.selectNext();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
handleEscape (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
e.preventDefault();
|
||||
this.$refs.autocomplete.cancel();
|
||||
}
|
||||
},
|
||||
|
||||
selectNextAutocomplete (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
e.preventDefault();
|
||||
this.$refs.autocomplete.selectNext();
|
||||
}
|
||||
},
|
||||
|
||||
selectPreviousAutocomplete (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
e.preventDefault();
|
||||
this.$refs.autocomplete.selectPrevious();
|
||||
}
|
||||
},
|
||||
|
||||
selectAutocomplete (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
if (this.$refs.autocomplete.selected !== null) {
|
||||
e.preventDefault();
|
||||
this.$refs.autocomplete.makeSelection();
|
||||
} else {
|
||||
// no autocomplete selected, newline instead
|
||||
this.$refs.autocomplete.cancel();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
selectedAutocomplete (newText, newCaret) {
|
||||
this.newMessage = newText;
|
||||
// Wait for v-modal to update
|
||||
@@ -273,7 +194,6 @@ export default {
|
||||
this.textbox.focus();
|
||||
});
|
||||
},
|
||||
|
||||
fetchRecentMessages () {
|
||||
this.$emit('fetchRecentMessages');
|
||||
},
|
||||
@@ -284,10 +204,7 @@ export default {
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
// Reset chat
|
||||
this.newMessage = '';
|
||||
this.coords = {
|
||||
TOP: 0,
|
||||
LEFT: 0,
|
||||
};
|
||||
this.autoCompleteMixinResetCoordsPosition();
|
||||
|
||||
next();
|
||||
},
|
||||
|
||||
@@ -225,10 +225,9 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="quest-icon">
|
||||
<div
|
||||
<Sprite
|
||||
class="quest"
|
||||
:class="`inventory_quest_scroll_${questData.key}`"
|
||||
></div>
|
||||
:image-name="`inventory_quest_scroll_${questData.key}`" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -12,20 +12,21 @@
|
||||
<strong> {{ notification.data.title }} </strong>
|
||||
<span> {{ notification.data.text }} </span>
|
||||
</div>
|
||||
<div
|
||||
<Sprite
|
||||
slot="icon"
|
||||
class="mt-3"
|
||||
:class="notification.data.icon"
|
||||
></div>
|
||||
:image-name="notification.data.icon" />
|
||||
</base-notification>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseNotification from './base';
|
||||
import Sprite from '@/components/ui/sprite.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BaseNotification,
|
||||
Sprite,
|
||||
},
|
||||
props: {
|
||||
notification: {
|
||||
|
||||
@@ -10,20 +10,21 @@
|
||||
slot="content"
|
||||
v-html="$t('newSubscriberItem')"
|
||||
></div>
|
||||
<div
|
||||
<Sprite
|
||||
slot="icon"
|
||||
:class="mysteryClass"
|
||||
></div>
|
||||
:image-name="mysteryClass" />
|
||||
</base-notification>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import BaseNotification from './base';
|
||||
import Sprite from '@/components/ui/sprite.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BaseNotification,
|
||||
Sprite,
|
||||
},
|
||||
props: ['notification', 'canRemove'],
|
||||
computed: {
|
||||
|
||||
@@ -114,7 +114,6 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import some from 'lodash/some';
|
||||
import moment from 'moment';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { mapState } from '@/libs/store';
|
||||
@@ -183,13 +182,12 @@ export default {
|
||||
return 'GreyedOut';
|
||||
},
|
||||
imageName () {
|
||||
if (this.isOwned() && some(
|
||||
this.currentEventList,
|
||||
event => moment().isBetween(event.start, event.end) && event.aprilFools && event.aprilFools === 'Fungi',
|
||||
)) {
|
||||
if (this.isSpecial()) return `stable_${this.foolPet(this.item.key)}`;
|
||||
const foolEvent = this.currentEventList?.find(event => moment()
|
||||
.isBetween(event.start, event.end) && event.aprilFools);
|
||||
if (this.isOwned() && foolEvent) {
|
||||
if (this.isSpecial()) return `stable_${this.foolPet(this.item.key, foolEvent.aprilFools)}`;
|
||||
const petString = `${this.item.eggKey}-${this.item.key}`;
|
||||
return `stable_${this.foolPet(petString)}`;
|
||||
return `stable_${this.foolPet(petString, foolEvent.aprilFools)}`;
|
||||
}
|
||||
|
||||
if (this.isOwned() || (this.mountOwned() && this.isHatchable())) {
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
:name="member.profile.name"
|
||||
:backer="member.backer"
|
||||
:contributor="member.contributor"
|
||||
:smaller-style="true"
|
||||
/>
|
||||
<inline-class-badge
|
||||
v-if="member.stats"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div
|
||||
class="d-inline-flex like-button"
|
||||
@click="like()"
|
||||
>
|
||||
<div
|
||||
v-b-tooltip="{title: likeTooltip(likeCount)}"
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
v-if="likeCount > 0"
|
||||
class="action d-flex align-items-center mr-0"
|
||||
:class="{isLiked: true, currentUserLiked: likedByCurrentUser}"
|
||||
>
|
||||
<div
|
||||
class="svg-icon mr-1"
|
||||
:title="$t('liked')"
|
||||
v-html="likedIcon"
|
||||
></div>
|
||||
+{{ likeCount }}
|
||||
</div>
|
||||
<div
|
||||
v-if="likeCount === 0"
|
||||
class="action d-flex align-items-center mr-1"
|
||||
>
|
||||
<div
|
||||
class="svg-icon"
|
||||
:title="$t('like')"
|
||||
v-html="icons.like"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="likeCount === 0">{{ $t('like') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/tiers.scss';
|
||||
|
||||
.action {
|
||||
display: inline-block;
|
||||
margin-right: 1em;
|
||||
|
||||
.svg-icon {
|
||||
color: $gray-100;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&.isLiked.currentUserLiked {
|
||||
color: $purple-200;
|
||||
font-weight: bold;
|
||||
|
||||
.svg-icon {
|
||||
color: $purple-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.like-button {
|
||||
color: $gray-100;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
color: $purple-200;
|
||||
|
||||
.svg-icon {
|
||||
color: $purple-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import likeIcon from '@/assets/svg/like.svg';
|
||||
import likedIcon from '@/assets/svg/liked.svg';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
likeCount: {
|
||||
type: Number,
|
||||
},
|
||||
likedByCurrentUser: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
like: likeIcon,
|
||||
liked: likedIcon,
|
||||
}),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
likedIcon () {
|
||||
return this.likedByCurrentUser ? this.icons.liked : this.icons.like;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async like () {
|
||||
this.$emit('toggle-like');
|
||||
},
|
||||
likeTooltip (likedStatus) {
|
||||
if (!likedStatus) return this.$t('like');
|
||||
return null;
|
||||
},
|
||||
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,73 +1,157 @@
|
||||
<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="card"
|
||||
:class="{
|
||||
'system-message': isSystemMessage
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="text markdown"
|
||||
dir="auto"
|
||||
v-html="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"
|
||||
v-b-tooltip.hover="messageDateForSystemMessage"
|
||||
class="message-card"
|
||||
|
||||
:class="{
|
||||
'user-sent-message': userSentMessage,
|
||||
'user-received-message': !userSentMessage && !isSystemMessage,
|
||||
'system-message': isSystemMessage
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="!isMessageReported"
|
||||
class="action d-flex align-items-center"
|
||||
@click="report(msg)"
|
||||
v-if="isUserMentioned"
|
||||
class="mentioned-icon"
|
||||
></div>
|
||||
<div
|
||||
v-if="userIsModerator && msg.flagCount"
|
||||
class="message-hidden"
|
||||
>
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon"
|
||||
v-html="icons.report"
|
||||
></div>
|
||||
<div v-once>
|
||||
{{ $t('report') }}
|
||||
</div>
|
||||
{{ flagCountDescription }}
|
||||
</div>
|
||||
<div
|
||||
class="action d-flex align-items-center"
|
||||
@click="remove()"
|
||||
class="card-body"
|
||||
>
|
||||
<user-link
|
||||
v-if="!isSystemMessage"
|
||||
:user-id="msg.uuid"
|
||||
:name="msg.user"
|
||||
:backer="msg.backer"
|
||||
:contributor="msg.contributor"
|
||||
/>
|
||||
<p
|
||||
v-if="!isSystemMessage"
|
||||
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>
|
||||
|
||||
<b-dropdown
|
||||
v-if="!isSystemMessage"
|
||||
right="right"
|
||||
variant="flat"
|
||||
toggle-class="with-icon"
|
||||
class="card-menu no-min-width"
|
||||
:no-caret="true"
|
||||
>
|
||||
<template #button-content>
|
||||
<span
|
||||
v-once
|
||||
class="svg-icon inline menuIcon color"
|
||||
v-html="icons.menuIcon"
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
<b-dropdown-item
|
||||
class="selectListItem"
|
||||
@click="copy(msg)"
|
||||
>
|
||||
<span class="with-icon">
|
||||
<span
|
||||
v-once
|
||||
class="svg-icon icon-16 color"
|
||||
v-html="icons.copy"
|
||||
></span>
|
||||
<span v-once>
|
||||
{{ $t('copy') }}
|
||||
</span>
|
||||
</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
v-if="canReportMessage"
|
||||
class="selectListItem custom-hover--red"
|
||||
@click="report(msg)"
|
||||
>
|
||||
<span class="with-icon">
|
||||
<span
|
||||
v-once
|
||||
class="svg-icon icon-16 color"
|
||||
v-html="icons.report"
|
||||
></span>
|
||||
<span v-once>
|
||||
{{ $t('report') }}
|
||||
</span>
|
||||
</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
v-if="canDeleteMessage"
|
||||
class="selectListItem custom-hover--red"
|
||||
@click="remove()"
|
||||
>
|
||||
<span class="with-icon">
|
||||
<span
|
||||
v-once
|
||||
class="svg-icon icon-16 color"
|
||||
v-html="icons.delete"
|
||||
></span>
|
||||
<span v-once>
|
||||
{{ $t('delete') }}
|
||||
</span>
|
||||
</span>
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon"
|
||||
v-html="icons.delete"
|
||||
></div>
|
||||
<div v-once>
|
||||
{{ $t('delete') }}
|
||||
v-if="isSystemMessage"
|
||||
class="system-message-body"
|
||||
>
|
||||
{{ msg.unformattedText }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="markdownContainer"
|
||||
class="text markdown"
|
||||
dir="auto"
|
||||
v-html="parseMarkdown(msg.text)"
|
||||
></div>
|
||||
<div
|
||||
v-if="isMessageReported"
|
||||
class="reported"
|
||||
>
|
||||
<span v-once>{{ $t('reportedMessage') }}</span><br>
|
||||
<span v-once>{{ $t('canDeleteNow') }}</span>
|
||||
</div>
|
||||
|
||||
<like-button
|
||||
v-if="canLikeMessage"
|
||||
class="mt-75"
|
||||
:liked-by-current-user="msg.likes[user._id]"
|
||||
:like-count="likeCount"
|
||||
@toggle-like="like()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.message-card {
|
||||
.at-highlight {
|
||||
background-color: rgba(213, 200, 255, 0.32);
|
||||
padding: 0.1rem;
|
||||
@@ -76,43 +160,76 @@
|
||||
.at-text {
|
||||
color: #6133b4;
|
||||
}
|
||||
|
||||
.card-menu button {
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.markdown p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/tiers.scss';
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/tiers.scss';
|
||||
|
||||
.action {
|
||||
display: inline-block;
|
||||
color: $gray-200;
|
||||
margin-right: 1em;
|
||||
font-size: 12px;
|
||||
.card {
|
||||
background: transparent !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.message-card:not(.system-message) {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
color: $gray-300;
|
||||
margin-right: .2em;
|
||||
width: 16px;
|
||||
}
|
||||
.mentioned-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: $purple-500;
|
||||
box-shadow: 0 1px 1px 0 rgba(26, 24, 29, 0.12);
|
||||
position: absolute;
|
||||
right: -.5em;
|
||||
top: -.5em;
|
||||
}
|
||||
|
||||
.message-hidden {
|
||||
margin-left: 1.5em;
|
||||
margin-top: 1em;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: $purple-300;
|
||||
|
||||
.svg-icon {
|
||||
color: $purple-400;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
color: $purple-300;
|
||||
.message-card {
|
||||
border-radius: 7px;
|
||||
margin: 0;
|
||||
padding: 1rem 0.75rem 0.5rem 1rem;
|
||||
|
||||
.svg-icon {
|
||||
color: $purple-400;
|
||||
}
|
||||
&.system-message {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: $gray-200;
|
||||
color: $gray-100;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -123,49 +240,173 @@
|
||||
min-height: 0rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.card-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
.reported {
|
||||
margin-top: 18px;
|
||||
color: $red-50;
|
||||
&:not(.show) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body:hover {
|
||||
.card-menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.reported {
|
||||
margin-top: 18px;
|
||||
color: $red-50;
|
||||
}
|
||||
|
||||
.selectListItem:not(:hover) .svg-icon.icon-16.color {
|
||||
color: #{$gray-100}
|
||||
}
|
||||
|
||||
.custom-hover--red {
|
||||
--hover-color: #{$maroon-50};
|
||||
--hover-background: #{rgba($red-500, 0.25)};
|
||||
}
|
||||
|
||||
.user-sent-message {
|
||||
border: 1px solid $purple-400;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
border: 1px solid $purple-400;
|
||||
}
|
||||
|
||||
.user-received-message {
|
||||
border: 1px solid $gray-500;
|
||||
}
|
||||
|
||||
.card-menu {
|
||||
// icon-color is the menu icon itself
|
||||
--icon-color: #{$gray-100};
|
||||
|
||||
--dropdown-item-hover-icon-color: #{$gray-100};
|
||||
|
||||
&:hover {
|
||||
--icon-color: #{$purple-300};
|
||||
}
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
width: 4px;
|
||||
height: 1rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.system-message-body {
|
||||
line-height: 1.71;
|
||||
text-align: center;
|
||||
color: $purple-300;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import escapeRegExp from 'lodash/escapeRegExp';
|
||||
import { CHAT_FLAG_FROM_SHADOW_MUTE, CHAT_FLAG_LIMIT_FOR_HIDING } from '@/../../common/script/constants';
|
||||
import externalLinks from '../../mixins/externalLinks';
|
||||
|
||||
import { CopyToClipboardMixin } from '@/mixins/copyToClipboard';
|
||||
|
||||
import renderWithMentions from '@/libs/renderWithMentions';
|
||||
import { mapState } from '@/libs/store';
|
||||
import userLink from '../userLink';
|
||||
|
||||
import deleteIcon from '@/assets/svg/delete.svg';
|
||||
import reportIcon from '@/assets/svg/report.svg';
|
||||
import menuIcon from '@/assets/svg/menu.svg';
|
||||
import { userStateMixin } from '@/mixins/userState';
|
||||
import copyIcon from '@/assets/svg/copy.svg';
|
||||
import LikeButton from '@/components/messages/likeButton.vue';
|
||||
|
||||
const LikeLogicMixin = {
|
||||
computed: {
|
||||
likeCount () {
|
||||
const message = this.msg;
|
||||
if (!message.likes) return 0;
|
||||
|
||||
let likeCount = 0;
|
||||
for (const key of Object.keys(message.likes)) {
|
||||
const like = message.likes[key];
|
||||
if (like) likeCount += 1;
|
||||
}
|
||||
return likeCount;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
async like () {
|
||||
const message = cloneDeep(this.msg);
|
||||
|
||||
await this.$store.dispatch('chat:like', {
|
||||
groupId: this.groupId,
|
||||
chatMessageId: this.privateMessageMode ? message.uniqueMessageId : message.id,
|
||||
});
|
||||
|
||||
message.likes[this.user._id] = !message.likes[this.user._id];
|
||||
|
||||
this.$emit('message-liked', message);
|
||||
this.$root.$emit('bv::hide::tooltip');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LikeButton,
|
||||
userLink,
|
||||
},
|
||||
filters: {
|
||||
timeAgo (value) {
|
||||
return moment(value).fromNow();
|
||||
},
|
||||
date (value) {
|
||||
// @TODO: Vue doesn't support this so we cant user preference
|
||||
return moment(value).toDate().toString();
|
||||
},
|
||||
},
|
||||
mixins: [externalLinks],
|
||||
mixins: [
|
||||
externalLinks, userStateMixin, LikeLogicMixin,
|
||||
CopyToClipboardMixin,
|
||||
],
|
||||
props: {
|
||||
msg: {},
|
||||
msg: {
|
||||
type: Object,
|
||||
},
|
||||
groupId: {
|
||||
type: String,
|
||||
},
|
||||
privateMessageMode: {
|
||||
type: Boolean,
|
||||
},
|
||||
userSentMessage: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
delete: deleteIcon,
|
||||
report: reportIcon,
|
||||
copy: copyIcon,
|
||||
menuIcon,
|
||||
}),
|
||||
reported: false,
|
||||
};
|
||||
@@ -175,19 +416,100 @@ export default {
|
||||
isMessageReported () {
|
||||
return (this.msg.flags && this.msg.flags[this.user.id]) || this.reported;
|
||||
},
|
||||
messageDateForSystemMessage () {
|
||||
return this.isSystemMessage ? this.messageDate : '';
|
||||
},
|
||||
messageDate () {
|
||||
const date = moment(this.msg.timestamp).toDate();
|
||||
return date.toString();
|
||||
},
|
||||
userIsModerator () {
|
||||
return this.hasPermission(this.user, 'moderator');
|
||||
},
|
||||
isSystemMessage () {
|
||||
return this.msg.uuid === 'system';
|
||||
},
|
||||
canLikeMessage () {
|
||||
if (this.isSystemMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.privateMessageMode) {
|
||||
return Boolean(this.msg.uniqueMessageId);
|
||||
}
|
||||
|
||||
return this.msg.id;
|
||||
},
|
||||
canDeleteMessage () {
|
||||
return this.privateMessageMode
|
||||
|| this.msg.uuid === this.user._id
|
||||
|| this.userIsModerator;
|
||||
},
|
||||
canReportMessage () {
|
||||
if (this.privateMessageMode) {
|
||||
return !this.isMessageReported;
|
||||
}
|
||||
return (this.user.flags.communityGuidelinesAccepted && this.msg.uuid !== 'system')
|
||||
&& (!this.isMessageReported || this.userIsModerator);
|
||||
},
|
||||
isUserMentioned () {
|
||||
const message = this.msg;
|
||||
|
||||
if (message.highlight) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = this;
|
||||
const displayName = user.profile.name;
|
||||
const { username } = user.auth.local;
|
||||
const pattern = `@(${escapeRegExp(displayName)}|${escapeRegExp(username)})(\\b)`;
|
||||
message.highlight = new RegExp(pattern, 'i').test(message.text);
|
||||
|
||||
return message.highlight;
|
||||
},
|
||||
flagCountDescription () {
|
||||
if (!this.msg.flagCount) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (this.msg.flagCount < CHAT_FLAG_LIMIT_FOR_HIDING) {
|
||||
return 'Message flagged once, not hidden';
|
||||
}
|
||||
|
||||
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) {
|
||||
return 'Message hidden';
|
||||
}
|
||||
|
||||
return 'Message hidden (shadow-muted)';
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.$emit('message-card-mounted');
|
||||
this.handleExternalLinks();
|
||||
this.mapProfileLinksToModal();
|
||||
},
|
||||
updated () {
|
||||
this.handleExternalLinks();
|
||||
this.mapProfileLinksToModal();
|
||||
},
|
||||
methods: {
|
||||
mapProfileLinksToModal () {
|
||||
const links = this.$refs.markdownContainer.getElementsByTagName('a');
|
||||
for (let i = 0; i < links.length; i += 1) {
|
||||
let link = links[i].pathname;
|
||||
|
||||
// Internet Explorer does not provide the leading slash character in the pathname
|
||||
link = link.charAt(0) === '/' ? link : `/${link}`;
|
||||
|
||||
if (link.startsWith('/profile/')) {
|
||||
links[i].onclick = ev => {
|
||||
ev.preventDefault();
|
||||
this.$router.push({ path: link });
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
report () {
|
||||
this.$root.$on('habitica:report-result', data => {
|
||||
if (data.ok) {
|
||||
@@ -199,16 +521,29 @@ export default {
|
||||
|
||||
this.$root.$emit('habitica::report-chat', {
|
||||
message: this.msg,
|
||||
groupId: 'privateMessage',
|
||||
groupId: this.groupId,
|
||||
});
|
||||
},
|
||||
async remove () {
|
||||
if (!window.confirm(this.$t('areYouSureDeleteMessage'))) return; // eslint-disable-line no-alert
|
||||
// eslint-disable-next-line no-alert
|
||||
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}`);
|
||||
if (this.privateMessageMode) {
|
||||
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
|
||||
} else {
|
||||
await this.$store.dispatch('chat:deleteChat', {
|
||||
groupId: this.groupId,
|
||||
chatId: message.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
copy (message) {
|
||||
this.mixinCopyToClipboard(message.text, this.$t('messageCopiedToClipboard'));
|
||||
},
|
||||
parseMarkdown (text) {
|
||||
return renderWithMentions(text, this.user);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="container-fluid"
|
||||
class="message-list"
|
||||
>
|
||||
<div class="row loadmore">
|
||||
<div class="loadmore">
|
||||
<div v-if="canLoadMore && !isLoading">
|
||||
<div class="loadmore-divider-holder">
|
||||
<div class="loadmore-divider"></div>
|
||||
@@ -28,7 +28,7 @@
|
||||
<div
|
||||
v-for="(msg) in messages"
|
||||
:key="msg.id"
|
||||
class="row message-row"
|
||||
class="message-row"
|
||||
:class="{ 'margin-right': user._id !== msg.uuid}"
|
||||
>
|
||||
<div
|
||||
@@ -39,28 +39,33 @@
|
||||
class="avatar-left"
|
||||
:member="conversationOpponentUser"
|
||||
:avatar-only="true"
|
||||
:override-top-padding="'14px'"
|
||||
:show-weapon="true"
|
||||
:debug-mode="false"
|
||||
:height="null"
|
||||
:override-top-padding="'0'"
|
||||
:hide-class-badge="true"
|
||||
@click.native="showMemberModal(msg.uuid)"
|
||||
/>
|
||||
<div
|
||||
class="card"
|
||||
:class="{'card-right': user._id !== msg.uuid, 'card-left': user._id === msg.uuid}"
|
||||
>
|
||||
<message-card
|
||||
:msg="msg"
|
||||
@message-removed="messageRemoved"
|
||||
@show-member-modal="showMemberModal"
|
||||
@message-card-mounted="itemWasMounted"
|
||||
/>
|
||||
</div>
|
||||
<message-card
|
||||
:msg="msg"
|
||||
:user-sent-message="user._id === msg.uuid"
|
||||
:group-id="'privateMessage'"
|
||||
:private-message-mode="true"
|
||||
@message-liked="messageLiked"
|
||||
@message-removed="messageRemoved"
|
||||
@show-member-modal="showMemberModal"
|
||||
@message-card-mounted="itemWasMounted"
|
||||
/>
|
||||
<avatar
|
||||
v-if="user && user._id === msg.uuid"
|
||||
class="avatar-right"
|
||||
:member="user"
|
||||
:height="null"
|
||||
:avatar-only="true"
|
||||
:show-weapon="true"
|
||||
:debug-mode="false"
|
||||
:hide-class-badge="true"
|
||||
:override-top-padding="'14px'"
|
||||
:override-top-padding="'0'"
|
||||
@click.native="showMemberModal(msg.uuid)"
|
||||
/>
|
||||
</div>
|
||||
@@ -69,121 +74,110 @@
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.avatar {
|
||||
width: 170px;
|
||||
min-width: 8rem;
|
||||
height: 120px;
|
||||
padding-top: 0 !important;
|
||||
.avatar-left, .avatar-right {
|
||||
align-self: center;
|
||||
|
||||
::v-deep .character-sprites {
|
||||
margin-bottom: -5px !important;
|
||||
padding-bottom: 0 !important;
|
||||
margin-top: -1px !important;
|
||||
}
|
||||
|
||||
.avatar-right {
|
||||
margin-left: -1rem;
|
||||
::v-deep .avatar {
|
||||
margin-left: -1.75rem;
|
||||
margin-right: -0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .character-sprites {
|
||||
margin-right: 1rem !important;
|
||||
.card {
|
||||
border: 0px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0rem;
|
||||
width: 684px;
|
||||
|
||||
}
|
||||
|
||||
.message-list {
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
margin-left: 12px;
|
||||
margin-right: 0;
|
||||
margin-bottom: 1.2rem;
|
||||
|
||||
&:not(.margin-right) {
|
||||
.d-flex {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 0px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0rem;
|
||||
width: 684px;
|
||||
}
|
||||
.message-row {
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
.hr {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
border-bottom: 1px solid $gray-500;
|
||||
text-align: center;
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
&:not(.margin-right) {
|
||||
.d-flex {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 1200px) {
|
||||
.card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1400px) {
|
||||
.message-row {
|
||||
margin-left: -15px;
|
||||
margin-right: -30px;
|
||||
}
|
||||
}
|
||||
.loadmore {
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-left {
|
||||
border: 1px solid $purple-500;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
border: 1px solid $gray-500;
|
||||
}
|
||||
|
||||
.hr {
|
||||
> div {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
border-bottom: 1px solid $gray-500;
|
||||
text-align: center;
|
||||
margin: 2em 0;
|
||||
}
|
||||
align-items: center;
|
||||
|
||||
.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;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
text-align: center;
|
||||
color: $gray-50;
|
||||
}
|
||||
button {
|
||||
text-align: center;
|
||||
color: $gray-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loadmore-divider-holder {
|
||||
flex: 1;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
.loadmore-divider-holder {
|
||||
flex: 1;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loadmore-divider {
|
||||
height: 1px;
|
||||
border-top: 1px $gray-500 solid;
|
||||
width: 100%;
|
||||
.loadmore-divider {
|
||||
height: 1px;
|
||||
border-top: 1px $gray-500 solid;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.loading {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -280,6 +274,9 @@ export default {
|
||||
// container.style.overflowY = 'scroll';
|
||||
}
|
||||
}, 50),
|
||||
messageLiked (message) {
|
||||
this.$emit('message-liked', message);
|
||||
},
|
||||
messageRemoved (message) {
|
||||
this.$emit('message-removed', message);
|
||||
},
|
||||
|
||||
@@ -692,7 +692,7 @@
|
||||
<div class="form-inline clearfix">
|
||||
<Sprite
|
||||
class="pull-left"
|
||||
:class="'inventory_quest_scroll_' + item.key"
|
||||
:image-name="'inventory_quest_scroll_' + item.key"
|
||||
style="margin-right: 10px"
|
||||
/>
|
||||
<p>{{ item.text() }}</p>
|
||||
|
||||
@@ -843,7 +843,6 @@ export default {
|
||||
purchasedPlanIdInfo () {
|
||||
if (!this.subscriptionBlocks[this.user.purchased.plan.planId]) {
|
||||
// @TODO: find which subs are in the common
|
||||
// console.log(this.subscriptionBlocks
|
||||
// [this.user.purchased.plan.planId]); // eslint-disable-line
|
||||
return {
|
||||
price: 0,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div
|
||||
v-for="currency of currencies"
|
||||
:key="currency.key"
|
||||
:needed-currency-only="neededCurrencyOnly"
|
||||
class="d-flex align-items-center"
|
||||
>
|
||||
<div
|
||||
@@ -54,6 +55,9 @@ export default {
|
||||
amountNeeded: {
|
||||
type: Number,
|
||||
},
|
||||
neededCurrencyOnly: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -66,34 +70,34 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
currencies () {
|
||||
const currencies = [];
|
||||
currencies.push({
|
||||
const currencies = [{
|
||||
type: 'hourglasses',
|
||||
icon: this.icons.hourglasses,
|
||||
value: this.userHourglasses,
|
||||
});
|
||||
},
|
||||
|
||||
currencies.push({
|
||||
{
|
||||
type: 'gems',
|
||||
icon: this.icons.gem,
|
||||
value: this.userGems,
|
||||
});
|
||||
},
|
||||
|
||||
currencies.push({
|
||||
{
|
||||
type: 'gold',
|
||||
icon: this.icons.gold,
|
||||
value: this.userGold,
|
||||
});
|
||||
}];
|
||||
|
||||
for (const currency of currencies) {
|
||||
if (
|
||||
currency.type === this.currencyNeeded
|
||||
&& !this.enoughCurrency(this.currencyNeeded, this.amountNeeded)
|
||||
if (currency.type === this.currencyNeeded
|
||||
&& !this.enoughCurrency(this.currencyNeeded, this.amountNeeded)
|
||||
) {
|
||||
currency.notEnough = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.neededCurrencyOnly) {
|
||||
return currencies.filter(curr => curr.type === this.currencyNeeded);
|
||||
}
|
||||
return currencies;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
}"
|
||||
>
|
||||
<input
|
||||
ref="textInput"
|
||||
:value="value"
|
||||
class="form-control"
|
||||
:type="inputType"
|
||||
@@ -29,19 +30,23 @@
|
||||
}"
|
||||
:readonly="readonly"
|
||||
:aria-readonly="readonly"
|
||||
autocomplete="off"
|
||||
|
||||
:placeholder="placeholder"
|
||||
@keyup="handleChange"
|
||||
@keyup.enter="$emit('enter')"
|
||||
@blur="$emit('blur')"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-for="issue in invalidIssues"
|
||||
:key="issue"
|
||||
class="input-error"
|
||||
>
|
||||
{{ issue }}
|
||||
</div>
|
||||
<template v-if="!hideErrorLine">
|
||||
<div
|
||||
v-for="issue in invalidIssues"
|
||||
:key="issue"
|
||||
class="input-error"
|
||||
>
|
||||
{{ issue }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -85,6 +90,10 @@ export default {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
hideErrorLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -107,6 +116,9 @@ export default {
|
||||
this.wasChanged = true;
|
||||
this.$emit('update:value', value);
|
||||
},
|
||||
focus () {
|
||||
this.$refs.textInput.focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -128,4 +140,12 @@ export default {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* this removes safari "save username" UI, we only search for one, we dont want to save it */
|
||||
input::-webkit-contacts-auto-fill-button,
|
||||
input::-webkit-credentials-auto-fill-button {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -29,20 +29,12 @@
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.user-link { // this is the user name
|
||||
font-family: 'Roboto Condensed', sans-serif;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
|
||||
// currently used in the member-details-new.vue
|
||||
&.smaller {
|
||||
font-family: Roboto;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1.71;
|
||||
}
|
||||
font-size: 14px;
|
||||
line-height: 1.71;
|
||||
display: inline-flex !important;
|
||||
|
||||
&.no-tier {
|
||||
color: $gray-50;
|
||||
@@ -111,7 +103,6 @@ export default {
|
||||
'backer',
|
||||
'contributor',
|
||||
'hideTooltip',
|
||||
'smallerStyle',
|
||||
'showBuffed',
|
||||
'context',
|
||||
],
|
||||
@@ -173,7 +164,7 @@ export default {
|
||||
return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || '';
|
||||
},
|
||||
levelStyle () {
|
||||
return `${this.userLevelStyleFromLevel(this.level, this.isNPC)} ${this.smallerStyle ? 'smaller' : ''}`;
|
||||
return `${this.userLevelStyleFromLevel(this.level, this.isNPC)}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
export const autoCompleteHelperMixin = {
|
||||
data () {
|
||||
return {
|
||||
mixinData: {
|
||||
autoComplete: {
|
||||
caretPosition: 0,
|
||||
coords: {
|
||||
TOP: 0,
|
||||
LEFT: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
autoCompleteMixinHandleTab (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
this.$refs.autocomplete.selectPrevious();
|
||||
} else {
|
||||
this.$refs.autocomplete.selectNext();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
autoCompleteMixinHandleEscape (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
e.preventDefault();
|
||||
this.$refs.autocomplete.cancel();
|
||||
}
|
||||
},
|
||||
|
||||
autoCompleteMixinSelectNextAutocomplete (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
e.preventDefault();
|
||||
this.$refs.autocomplete.selectNext();
|
||||
}
|
||||
},
|
||||
|
||||
autoCompleteMixinSelectPreviousAutocomplete (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
e.preventDefault();
|
||||
this.$refs.autocomplete.selectPrevious();
|
||||
}
|
||||
},
|
||||
|
||||
autoCompleteMixinSelectAutocomplete (e) {
|
||||
if (this.$refs.autocomplete.searchActive) {
|
||||
if (this.$refs.autocomplete.selected !== null) {
|
||||
e.preventDefault();
|
||||
this.$refs.autocomplete.makeSelection();
|
||||
} else {
|
||||
// no autocomplete selected, newline instead
|
||||
this.$refs.autocomplete.cancel();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
autoCompleteMixinUpdateCarretPosition: debounce(function updateCarretPosition (eventUpdate) {
|
||||
this._updateCarretPosition(eventUpdate);
|
||||
}, 250),
|
||||
|
||||
autoCompleteMixinResetCoordsPosition () {
|
||||
this.mixinData.autoComplete.coords = {
|
||||
TOP: 0,
|
||||
LEFT: 0,
|
||||
};
|
||||
},
|
||||
|
||||
// https://medium.com/@_jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a
|
||||
_getCoord (e, text) {
|
||||
const caretPosition = text.selectionEnd;
|
||||
this.mixinData.autoComplete.caretPosition = caretPosition;
|
||||
|
||||
const div = document.createElement('div');
|
||||
const span = document.createElement('span');
|
||||
const copyStyle = getComputedStyle(text);
|
||||
|
||||
[].forEach.call(copyStyle, prop => {
|
||||
div.style[prop] = copyStyle[prop];
|
||||
});
|
||||
|
||||
div.style.position = 'absolute';
|
||||
document.body.appendChild(div);
|
||||
div.textContent = text.value.substr(0, caretPosition);
|
||||
span.textContent = text.value.substr(caretPosition) || '.';
|
||||
div.appendChild(span);
|
||||
this.mixinData.autoComplete.coords = {
|
||||
TOP: span.offsetTop,
|
||||
LEFT: span.offsetLeft,
|
||||
};
|
||||
document.body.removeChild(div);
|
||||
},
|
||||
_updateCarretPosition (eventUpdate) {
|
||||
const text = eventUpdate.target;
|
||||
this._getCoord(eventUpdate, text);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import notifications from './notifications';
|
||||
import { NotificationMixins } from './notifications';
|
||||
|
||||
export default {
|
||||
mixins: [notifications],
|
||||
export const CopyToClipboardMixin = {
|
||||
mixins: [NotificationMixins],
|
||||
methods: {
|
||||
async mixinCopyToClipboard (valueToCopy, notificationToShow = null) {
|
||||
if (navigator.clipboard) {
|
||||
@@ -21,3 +21,5 @@ export default {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default CopyToClipboardMixin;
|
||||
|
||||
@@ -2,54 +2,55 @@ import includes from 'lodash/includes';
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
foolPet (pet) {
|
||||
foolPet (pet, prank) {
|
||||
const SPECIAL_PETS = [
|
||||
'Wolf-Veteran',
|
||||
'Wolf-Cerberus',
|
||||
'Dragon-Hydra',
|
||||
'Turkey-Base',
|
||||
'BearCub-Polar',
|
||||
'MantisShrimp-Base',
|
||||
'JackOLantern-Base',
|
||||
'Mammoth-Base',
|
||||
'Tiger-Veteran',
|
||||
'Phoenix-Base',
|
||||
'Turkey-Gilded',
|
||||
'MagicalBee-Base',
|
||||
'Lion-Veteran',
|
||||
'Gryphon-RoyalPurple',
|
||||
'JackOLantern-Ghost',
|
||||
'Jackalope-RoyalPurple',
|
||||
'Orca-Base',
|
||||
'Bear-Veteran',
|
||||
'Hippogriff-Hopeful',
|
||||
'Fox-Veteran',
|
||||
'JackOLantern-Glow',
|
||||
'Gryphon-Gryphatrice',
|
||||
'Gryphatrice-Jubilant',
|
||||
'JackOLantern-RoyalPurple',
|
||||
'BearCub-Polar',
|
||||
'Cactus-Veteran',
|
||||
'Dragon-Hydra',
|
||||
'Dragon-Veteran',
|
||||
'Fox-Veteran',
|
||||
'Gryphatrice-Jubilant',
|
||||
'Gryphon-Gryphatrice',
|
||||
'Gryphon-RoyalPurple',
|
||||
'Hippogriff-Hopeful',
|
||||
'Jackalope-RoyalPurple',
|
||||
'JackOLantern-Base',
|
||||
'JackOLantern-Ghost',
|
||||
'JackOLantern-Glow',
|
||||
'JackOLantern-RoyalPurple',
|
||||
'Lion-Veteran',
|
||||
'MagicalBee-Base',
|
||||
'Mammoth-Base',
|
||||
'MantisShrimp-Base',
|
||||
'Orca-Base',
|
||||
'Phoenix-Base',
|
||||
'Tiger-Veteran',
|
||||
'Turkey-Base',
|
||||
'Turkey-Gilded',
|
||||
'Wolf-Cerberus',
|
||||
'Wolf-Veteran',
|
||||
];
|
||||
const BASE_PETS = [
|
||||
'Wolf',
|
||||
'TigerCub',
|
||||
'PandaCub',
|
||||
'LionCub',
|
||||
'Fox',
|
||||
'FlyingPig',
|
||||
'BearCub',
|
||||
'Dragon',
|
||||
'Cactus',
|
||||
'Dragon',
|
||||
'FlyingPig',
|
||||
'Fox',
|
||||
'LionCub',
|
||||
'PandaCub',
|
||||
'TigerCub',
|
||||
'Wolf',
|
||||
];
|
||||
if (!pet) return 'Pet-TigerCub-Fungi';
|
||||
if (!pet) return `Pet-TigerCub-${prank}`;
|
||||
if (SPECIAL_PETS.indexOf(pet) !== -1) {
|
||||
return 'Pet-Dragon-Fungi';
|
||||
return `Pet-Dragon-${prank}`;
|
||||
}
|
||||
const species = pet.slice(0, pet.indexOf('-'));
|
||||
if (includes(BASE_PETS, species)) {
|
||||
return `Pet-${species}-Fungi`;
|
||||
return `Pet-${species}-${prank}`;
|
||||
}
|
||||
return 'Pet-BearCub-Fungi';
|
||||
return `Pet-BearCub-${prank}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+620
-417
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -62,7 +62,7 @@
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import userLabel from '../userLabel';
|
||||
import userLabel from '../../components/userLabel.vue';
|
||||
|
||||
import dots from '@/assets/svg/dots.svg';
|
||||
import block from '@/assets/svg/block.svg';
|
||||
@@ -117,7 +117,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/colors';
|
||||
|
||||
.action-padding {
|
||||
height: 24px !important;
|
||||
@@ -153,7 +153,7 @@ export default {
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/colors';
|
||||
|
||||
.conversation {
|
||||
padding: 1rem 1.5rem;
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversations {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import { defineComponent } from 'vue';
|
||||
import habiticaMarkdown from 'habitica-markdown';
|
||||
import conversationItem from '@/pages/private-messages/pm-conversation-item.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: { conversationItem },
|
||||
props: {
|
||||
filtersConversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedConversation: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
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));
|
||||
},
|
||||
selectConversation (conversationKey) {
|
||||
this.$emit('selectConversation', conversationKey);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
class="pm-disabled-caption text-center"
|
||||
>
|
||||
<h4>{{ disabledTexts.title }}</h4>
|
||||
<p>{{ disabledTexts.description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.pm-disabled-caption {
|
||||
padding-top: 1.5em;
|
||||
z-index: 2;
|
||||
|
||||
h4, p {
|
||||
color: $gray-200;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['disabledTexts'],
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div
|
||||
class="empty-messages m-auto text-center empty-sidebar"
|
||||
>
|
||||
<div class="no-messages-box">
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon envelope mb-4"
|
||||
v-html="icons.mailIcon"
|
||||
></div>
|
||||
<strong
|
||||
v-once
|
||||
class="mb-1"
|
||||
>
|
||||
{{ $t('emptyMessagesLine1') }}
|
||||
</strong>
|
||||
<p v-if="!chatRevoked">
|
||||
{{ $t('emptyMessagesLine2') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary mt-4 d-flex align-items-center"
|
||||
@click="$emit('newMessageClicked')"
|
||||
>
|
||||
<div
|
||||
class="svg-icon icon-10 color mr-2"
|
||||
v-html="icons.positive"
|
||||
></div>
|
||||
{{ $t('newMessage') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
strong {
|
||||
line-height: 1.71;
|
||||
color: $gray-100;
|
||||
}
|
||||
|
||||
.svg-icon.icon-10 {
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import mailIcon from '@/assets/svg/mail.svg';
|
||||
import positiveIcon from '@/assets/svg/positive.svg';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
chatRevoked: Boolean,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
mailIcon,
|
||||
positive: positiveIcon,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
v-once
|
||||
class="centered empty-messages m-auto text-center"
|
||||
>
|
||||
<avatar
|
||||
v-if="memberObj"
|
||||
:member="memberObj"
|
||||
:avatar-only="true"
|
||||
:show-weapon="false"
|
||||
:hide-class-badge="true"
|
||||
:override-top-padding="'0px'"
|
||||
:sprites-margin="'0 0 0 -30px'"
|
||||
:debug-mode="false"
|
||||
:center-avatar="true"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<strong>{{ memberObj.profile.name }}</strong>
|
||||
<div class="username mb-3">
|
||||
@{{ memberObj.auth.local.username }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="kind-text"
|
||||
v-html="$t('rememberToBeKind')"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.centered {
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.center-avatar {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
strong {
|
||||
line-height: 1.71;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 12px;
|
||||
line-height: 1.33;
|
||||
color: $gray-100;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.kind-text {
|
||||
width: 330px;
|
||||
line-height: 1.71;
|
||||
color: $gray-100;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Avatar from '@/components/avatar.vue';
|
||||
|
||||
export default {
|
||||
components: { Avatar },
|
||||
props: {
|
||||
memberObj: null,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
export namespace PrivateMessages {
|
||||
// Shared properties between message types
|
||||
interface SharedMessageProps {
|
||||
username: string;
|
||||
contributor: Record<string, unknown>;
|
||||
userStyles: Record<string, unknown>;
|
||||
canReceive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the Type we get from our API
|
||||
*/
|
||||
interface ConversationSummaryMessageEntry extends SharedMessageProps {
|
||||
uuid: string;
|
||||
user: string;
|
||||
|
||||
timestamp: string;
|
||||
text: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Visual (Sidebar) Entry
|
||||
*/
|
||||
interface ConversationEntry extends SharedMessageProps {
|
||||
/**
|
||||
* UUID
|
||||
*/
|
||||
key: string;
|
||||
name: string;
|
||||
|
||||
|
||||
lastMessageText: '',
|
||||
canLoadMore: boolean;
|
||||
page: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded Private Messages, partial type
|
||||
*/
|
||||
interface PrivateMessageEntry extends SharedMessageProps {
|
||||
text: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="ml-4">
|
||||
<strong
|
||||
v-once
|
||||
v-html="$t('to')"
|
||||
></strong>
|
||||
<validated-text-input
|
||||
id="selectUser"
|
||||
ref="targetUserInput"
|
||||
v-model="targetUserInputValue"
|
||||
class="mx-2"
|
||||
:is-valid="foundUser._id"
|
||||
:only-show-invalid-state="foundUser._id === undefined"
|
||||
|
||||
:hide-error-line="true"
|
||||
:placeholder="$t('usernameOrUserId')"
|
||||
:invalid-issues="userInputInvalidIssues"
|
||||
@enter="triggerNewConversation"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="preventTrigger"
|
||||
@click="triggerNewConversation()"
|
||||
>
|
||||
{{ $t('confirm') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="ml-2 btn btn-secondary"
|
||||
@click="$emit('cancelNewConversation')"
|
||||
>
|
||||
{{ $t('cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div > * {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
strong {
|
||||
line-height: 1.71;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
input {
|
||||
border-radius: 2px;
|
||||
border-width: 2px;
|
||||
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
#selectUser {
|
||||
/* changing the style of validate-text-input to the same as others */
|
||||
::v-deep {
|
||||
.input-group {
|
||||
border-width: 2px;
|
||||
|
||||
input {
|
||||
width: 420px;
|
||||
height: 100%;
|
||||
color: $gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
&:focus, &:active, &:focus-within {
|
||||
border: solid 2px $purple-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import debounce from 'lodash/debounce';
|
||||
import isUUID from 'validator/es/lib/isUUID';
|
||||
import ValidatedTextInput from '@/components/ui/validatedTextInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ValidatedTextInput,
|
||||
},
|
||||
mixins: [],
|
||||
data () {
|
||||
return {
|
||||
targetUserInputValue: '',
|
||||
userNotFound: false,
|
||||
foundUser: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
preventTrigger () {
|
||||
return this.targetUserInputValue.length < 2;
|
||||
},
|
||||
userInputInvalidIssues () {
|
||||
return this.targetUserInputValue.length > 0 && this.userNotFound
|
||||
? [this.$t('userWithUsernameOrUserIdNotFound')]
|
||||
: [''];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
targetUserInputValue: {
|
||||
handler () {
|
||||
this.searchUser(this.targetUserInputValue.replace('@', ''));
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.$refs.targetUserInput.focus();
|
||||
},
|
||||
methods: {
|
||||
close () {
|
||||
this.$root.$emit('bv::hide::modal', 'select-user-modal');
|
||||
},
|
||||
searchUser: debounce(async function userSearch (searchTerm = '') {
|
||||
this.foundUser = {};
|
||||
if (searchTerm.length < 1) {
|
||||
this.userNotFound = false;
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
if (isUUID(searchTerm)) {
|
||||
try {
|
||||
result = await this.$store.dispatch('members:fetchMember', {
|
||||
memberId: searchTerm,
|
||||
});
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
result = await this.$store.dispatch('members:fetchMemberByUsername', {
|
||||
username: searchTerm,
|
||||
});
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
if (!result) {
|
||||
this.userNotFound = true;
|
||||
return;
|
||||
}
|
||||
this.userNotFound = false;
|
||||
this.foundUser = result;
|
||||
}, 500),
|
||||
triggerNewConversation () {
|
||||
const userWithoutAt = this.$refs.targetUserInput.value.replace('@', '');
|
||||
|
||||
this.$emit('startNewConversation', userWithoutAt);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -11,6 +11,7 @@
|
||||
class="balance-info"
|
||||
:currency-needed="currencyNeeded"
|
||||
:amount-needed="amountNeeded"
|
||||
:neededCurrencyOnly="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<your-balance
|
||||
:amount-needed="amountNeeded"
|
||||
currency-needed="gems"
|
||||
class="d-flex align-items-center"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -49,7 +49,7 @@ 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');
|
||||
const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages/index.vue');
|
||||
|
||||
// Challenges
|
||||
const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index');
|
||||
|
||||
@@ -5,3 +5,9 @@ export async function searchUsers (store, payload) {
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getUserHistory (store, payload) {
|
||||
const url = `/api/v4/admin/user/${payload.userIdentifier}/history`;
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,14 @@ export async function deleteChat (store, payload) {
|
||||
}
|
||||
|
||||
export async function like (store, payload) {
|
||||
const url = `/api/v4/groups/${payload.groupId}/chat/${payload.chatId}/like`;
|
||||
let url = '';
|
||||
|
||||
if (payload.groupId === 'privateMessage') {
|
||||
url = `/api/v4/inbox/like-private-message/${payload.chatMessageId}`;
|
||||
} else {
|
||||
url = `/api/v4/groups/${payload.groupId}/chat/${payload.chatMessageId}/like`;
|
||||
}
|
||||
|
||||
const response = await axios.post(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
@@ -32,3 +32,9 @@ export async function getHeroParty (store, payload) {
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getHeroGroupPlans (store, payload) {
|
||||
const url = `/api/v4/hall/heroes/${payload.heroId}/group-plans`;
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,11 @@ describe('LevelUp', () => {
|
||||
|
||||
it('generates the right test class for level 15', () => {
|
||||
const questClass = testFunction('questClass', 15);
|
||||
expect(questClass()).to.equal('inventory_quest_scroll_atom1');
|
||||
});
|
||||
|
||||
expect(questClass()).to.equal('scroll inventory_quest_scroll_atom1');
|
||||
it('generates empty test class for level 14', () => {
|
||||
const questClass = testFunction('questClass', 14);
|
||||
expect(questClass()).to.equal('');
|
||||
});
|
||||
});
|
||||
|
||||
+5
-3
@@ -1,14 +1,16 @@
|
||||
import Vue from 'vue';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
|
||||
import ChatCard from '@/components/chat/chatCard.vue';
|
||||
import BootstrapVue from 'bootstrap-vue';
|
||||
import MessageCard from '@/components/messages/messageCard.vue';
|
||||
import Store from '@/libs/store';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Store);
|
||||
localVue.use(Vue.directive('b-tooltip', {}));
|
||||
localVue.use(BootstrapVue);
|
||||
|
||||
describe('ChatCard', () => {
|
||||
describe('MessageCard', () => {
|
||||
function createMessage (text) {
|
||||
return { text, likes: {} };
|
||||
}
|
||||
@@ -26,7 +28,7 @@ describe('ChatCard', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallowMount(ChatCard, {
|
||||
wrapper = shallowMount(MessageCard, {
|
||||
propsData: { msg: message },
|
||||
store: new Store({
|
||||
state: {
|
||||
@@ -91,7 +91,7 @@
|
||||
"conText": "Якостта намалява щетите от отрицателни навици и пропуснати ежедневни задачи.",
|
||||
"perception": "Усет",
|
||||
"perText": "Усетът увеличава спечеленото злато, а след отключването на пазара увеличава вероятността за намиране на предмети след приключване на задачи.",
|
||||
"intelligence": "Интелигентност",
|
||||
"intelligence": "Интелект",
|
||||
"intText": "Интелигентността увеличава спечеления опит, а след отключването на класовете определя максималната мана за използване за класовите умения.",
|
||||
"levelBonus": "Бонус за ниво",
|
||||
"allocatedPoints": "Разпределени точки",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"quests": "Мисии",
|
||||
"quest": "мисия",
|
||||
"petQuests": "Мисии за любимци и превози",
|
||||
"petQuests": "Мисии за домашни любимци и ездитни животни",
|
||||
"unlockableQuests": "Мисии, които могат да бъдат отключени",
|
||||
"goldQuests": "Последователности от мисии на класовите повелители",
|
||||
"questDetails": "Подробности за мисията",
|
||||
@@ -29,7 +29,7 @@
|
||||
"collected": "Събрани",
|
||||
"abort": "Прекратяване",
|
||||
"leaveQuest": "Напускане на мисията",
|
||||
"sureLeave": "Наистина ли искате да се откажете от текущата мисия? Ще загубите целия си напредък.",
|
||||
"sureLeave": "Сигурни ли сте, че искате да се откажете от мисията? Ще загубите целия си напредък.",
|
||||
"mustComplete": "Трябва първо да завършите <%= quest %>.",
|
||||
"mustLvlQuest": "Трябва да бъдете ниво <%= level %>, за да купите тази мисия!",
|
||||
"unlockByQuesting": "За да отключите тази мисия, първо завършете <%= title %>.",
|
||||
@@ -78,5 +78,8 @@
|
||||
"questAlreadyStartedFriendly": "Мисията вече е започнала, но винаги може да хванете следващата!",
|
||||
"questAlreadyStarted": "Мисията вече е започнала.",
|
||||
"questInvitationNotificationInfo": "Получихте покана за присъединяване към мисия",
|
||||
"hatchingPotionQuests": "Мисии за Магическа Излюпваща Отвара"
|
||||
"hatchingPotionQuests": "Мисии за Магическа Излюпваща Отвара",
|
||||
"bossDamage": "Нанесохте вреда на главатаря!",
|
||||
"questItemsPending": "<%= amount %> предмета ще бъдат събрани",
|
||||
"sureLeaveInactive": "Сигурни ли сте, че искате да се откажете от мисията? Няма да можете да участвате в нея."
|
||||
}
|
||||
|
||||
@@ -894,5 +894,11 @@
|
||||
"backgroundWinterLandscapeWithCabinNotes": "Macht es dir in einer Winterlandschaft mit einer Hütte gemütlich.",
|
||||
"backgroundOldFashionedTeaShopText": "Altmodischer Teeladen",
|
||||
"backgroundOldFashionedTeaShopNotes": "Genieße ein Getränk in einem Altmodischen Teeladen.",
|
||||
"backgrounds022025": "Set 129: Veröffentlicht im Februar 2025"
|
||||
"backgrounds022025": "Set 129: Veröffentlicht im Februar 2025",
|
||||
"backgrounds032025": "SET 130: Veröffentlicht im März 2025",
|
||||
"backgroundMountainSceneWithBlossomsText": "Bergszene mit Blüten",
|
||||
"backgroundMountainSceneWithBlossomsNotes": "Erlebe den entzückenden Anblick und Geruch einer Bergszene mit Blüten.",
|
||||
"backgroundGardenWithFlowerBedsNotes": "Genieße das Blühen des Frühlings in einem Garten mit Blumenbeeten.",
|
||||
"backgrounds0420205": "SET 131: Veröffentlicht im April 2025",
|
||||
"backgroundGardenWithFlowerBedsText": "Garten mit Blumenbeeten"
|
||||
}
|
||||
|
||||
@@ -395,5 +395,16 @@
|
||||
"hatchingPotionGingerbread": "Lebkuchen",
|
||||
"questEggCatText": "Kätzchen",
|
||||
"questEggCatMountText": "Katze",
|
||||
"questEggCatAdjective": "ein schelmisches"
|
||||
"questEggCatAdjective": "ein schelmisches",
|
||||
"questEggOtterText": "Otter",
|
||||
"questEggOtterMountText": "Otter",
|
||||
"hatchingPotionJade": "Jade",
|
||||
"questEggOtterAdjective": "Ein perfider",
|
||||
"questEggAlpacaText": "Alpaka",
|
||||
"questEggAlpacaMountText": "Alpaka",
|
||||
"questEggAlpacaAdjective": "ein überladenes",
|
||||
"hatchingPotionBalloon": "Ballon",
|
||||
"wackyPotionAddlNotes": "Kann nicht zum Reittier großgezogen oder für Quest-Haustier Eier benutzt werden.",
|
||||
"hatchingPotionCryptid": "Kryptisch",
|
||||
"wackyPotionNotes": "Schütte dies über ein Ei und es wird als Durchgeknalltes <%= potText(locale) %> Haustier schlüpfen."
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"webFaqAnswer31": "Wenn du eine Aufgabe erfüllst und HP verlierst, obwohl du das nicht hättest tun sollen, kam es zu einer Verzögerung, während der Server die auf anderen Plattformen vorgenommenen Änderungen synchronisiert hat. Wenn du zum Beispiel Gold oder Mana verwendest oder HP in der mobilen App verlierst und dann eine Aufgabe auf der Website erledigst, bestätigt der Server lediglich, dass alles synchronisiert ist.",
|
||||
"webFaqAnswer49": "Wenn Du Habitica mit anderen erleben möchtest, aber keine anderen Spieler kennst, ist die Suche nach einer Party die beste Option! Wenn Du bereits andere Spieler kennst, die eine Party haben, kannst Du deinen @Benutzernamen mit ihnen teilen, um eingeladen zu werden. Alternativ kannst Du auch eine neue Gruppe erstellen und sie mit ihrem @Nutzernamen oder ihrer E-Mail-Adresse einladen.\n\nUm eine Party zu erstellen oder zu suchen, wähle \"Party\" im Navigationsmenü und wähle dann die Option, die Dir am besten gefällt.",
|
||||
"webFaqAnswer62": "Gruppenpläne bieten dir die einzigartige Möglichkeit, anderen Mitgliedern deines Gruppenplans gemeinsame Aufgaben zuzuweisen. Wenn eine gemeinsame Aufgabe einem Mitglied zugewiesen wird, können andere Mitglieder sie nicht mehr erledigen.\n\nDu kannst eine Aufgabe auch mehreren Mitgliedern zuweisen. Wenn sich zum Beispiel alle Mitglieder die Zähne putzen müssen, erstellst du eine Aufgabe und weist sie jedem Mitglied zu. Jedes Mitglied kann die Aufgabe erledigen und sich seine individuelle Belohnung verdienen. Die Hauptaufgabe wird als erledigt angezeigt, sobald alle Mitglieder sie erledigt haben.",
|
||||
"webFaqAnswer32": "In Habitica gibt es vier Klassen: Krieger, Magier, Schurke und Heiler. Alle Spieler beginnen in der Klasse des Kriegers, bis du Stufe 10 erreicht hast. Sobald du Stufe 10 erreichst, hast du die Wahl, eine neue Klasse zu wählen oder als Krieger weiterzuspielen.\n\nJede Klasse verfügt über unterschiedliche Ausrüstungen und Fertigkeiten. Wenn du dich nicht für eine Klasse entscheiden möchtest, kannst du \"Aussteigen\" wählen. Wenn du dich dafür entscheidest, kannst du das Klassensystem später in den Einstellungen wieder aktivieren.",
|
||||
"webFaqAnswer32": "Alle Spieler beginnen in der Klasse des Kriegers, bis sie Stufe 10 erreicht haben. Sobald du Stufe 10 erreichst, hast du die Wahl, eine neue Klasse zu wählen oder als Krieger weiterzuspielen.\n\nJede Klasse verfügt über unterschiedliche Ausrüstungen und Fertigkeiten. Wenn du dich nicht für eine Klasse entscheiden möchtest, kannst du \"Abbrechen\" wählen. Wenn du dich später doch entscheidest, kannst du das Klassensystem in den Einstellungen wieder aktivieren.\n\nWenn Du Deine Klasse nach Level 10 noch einmal ändern möchtest, kannst Du die Sphäre der Wiedergeburt hierfür nutzen. Die Sphäre der Wiedergeburt ist mit Level 50 auf demMarktplatz für 6 Edelsteine verfügbar und mit Level 100 bekommst Du sie umsonst.\n\nAlternativ kannst Du Deine Klasse jederzeit in den Einstellungen für 3 Edelsteine ändern. Dies wird Dein Level nicht wie die Sphäre der Wiedergeburt zurücksetzen, aber es erlaubt Dir, die Fertigkeits-Punkte, die Du beim Leveln gesammelt hast, Deiner neuen Klasse zuzuordnen.",
|
||||
"sunsetFaqPara14": "<strong>Linguists</strong><br />Wir freuen uns auch weiterhin über Hilfe bei der Übersetzung der Apps und der Website und werden für qualifizierte Beiträge nach wie vor Beitragsstufen vergeben. Die Methode, mit der wir Übersetzungen annehmen, wird sich jedoch ändern. Wir möchten unsere Ressourcen auf die Unterstützung einer bestimmten Auswahl von Sprachen für alle Plattformen konzentrieren. Um dies zu erreichen, werden wir die Anzahl der für Übersetzungen verfügbaren Sprachen reduzieren. Zuvor nicht fertiggestellte Sprachen werden in Github archiviert. Wir hoffen, dass diese Änderung das plattformübergreifende Habitica-Erlebnis konsistenter macht. Sie können unsere aktuellsten Richtlinien für das Übersetzungsverfahren auf unserer Website lesen <a href='https://translate.habitica.com/projects/habitica/#information'>Übersetzungswebsite</a>.",
|
||||
"webFaqAnswer34": "Haustiere mögen Futter, das zu ihrer Farbe passt. Basis-Tiere sind die Ausnahme, aber alle Basis-Tiere mögen den gleichen Gegenstand. Im Folgenden siehst du, welche Nahrungsmittel jedes Haustier mag:\n\n * Basistiere mögen Fleisch\n * Weiße Haustiere mögen Milch\n * Wüstenhaustiere mögen Kartoffeln\n * Rote Haustiere mögen Erdbeeren\n * Schattentiere mögen Schokolade\n * Skelett-Tiere mögen Fisch\n * Zombie-Tiere mögen verdorbenes Fleisch\n * Zuckerwatte rosa Haustiere mögen rosa Zuckerwatte\n * Zuckerwatte blaue Haustiere mögen blaue Zuckerwatte\n * Goldene Haustiere mögen Honig",
|
||||
"webFaqAnswer35": "Sobald du dein Haustier genug gefüttert hast, um es zu einem Reittier zu machen, musst du diese Art von Haustier erneut ausbrüten, um es in deinem Stall zu haben.\n\nUm Reittiere in den mobilen Apps zu sehen:\n\n * Wähle im Menü \"Haustiere & Reittiere\" und wechseln zur Registerkarte \"Reittiere\".\n\nSo zeigst du Reittiere auf der Website an:\n\n * Wähle im Menü \"Inventar\" die Option \"Haustiere und Reittiere\" und scrollen nach unten zum Abschnitt \"Reittiere\"",
|
||||
@@ -36,7 +36,7 @@
|
||||
"faqQuestion30": "Was passiert, wenn ich keine HP mehr habe?",
|
||||
"webFaqAnswer30": "Wenn deine HP Null erreichen, verlierst du eine Stufe, dein gesamtes Gold und ein Ausrüstungsstück, das du wieder kaufen kannst.",
|
||||
"faqQuestion31": "Warum habe ich HP verloren, wenn ich mit einer nicht-negativen Aufgabe interagiere?",
|
||||
"faqQuestion32": "Wann kann ich eine Klasse wählen?",
|
||||
"faqQuestion32": "Wie kann ich eine Klasse wählen?",
|
||||
"faqQuestion33": "Was ist der blaue Balken, der nach Level 10 erscheint?",
|
||||
"webFaqAnswer33": "Nachdem du das Klassensystem freigeschaltet hast, schaltest du auch Fertigkeiten frei, die Mana benötigen, um genutzt werden zu können. Mana wird durch deinen INT-Wert bestimmt und kann durch Fertigkeiten und Ausrüstung angepasst werden.",
|
||||
"faqQuestion34": "Welches Futter mag mein Haustier?",
|
||||
|
||||
@@ -3263,5 +3263,55 @@
|
||||
"shieldMystery202502Text": "Herzvolle Harlekin Ballons",
|
||||
"shieldMystery202502Notes": "Möge dein Herz an diesen Valentinstag, und auch jeden anderen Tag, so leicht sein wie diese beschwingten Luftballons. Gewährt keinen Attributbonus. Februar 2025 Abonnentengegenstand.",
|
||||
"shieldArmoireFancyFloralFanText": "Bezaubernder Blumenfächer",
|
||||
"headArmoireFancyFloralHatNotes": "Bestaune diesen bezaubernden Hut voller hinreißender Blumen und verschnörkelten Schnallen. Erhöht Intelligenz um <%= int %>. Verzauberter Schrank: Bezauberndes Blumen Zubehör Set (Gegenstand 1 von 2)."
|
||||
"headArmoireFancyFloralHatNotes": "Bestaune diesen bezaubernden Hut voller hinreißender Blumen und verschnörkelten Schnallen. Erhöht Intelligenz um <%= int %>. Verzauberter Schrank: Bezauberndes Blumen Zubehör Set (Gegenstand 1 von 2).",
|
||||
"weaponSpecialSpring2025WarriorNotes": "Mit einem Schnitt kannst du durch Blumenstängel säbeln, um ein Bukett zu machen, oder direkt durch Hindernisse, um deine Aufgaben zu erfüllen. Erhöht Stärke um <%= str %>. Limitierte Ausgabe Frühlingsausrüstung 2025.",
|
||||
"weaponSpecialSpring2025WarriorText": "Sonnenschein-Säbel",
|
||||
"weaponSpecialSpring2025RogueText": "Kristallspitzen-Morgenstern",
|
||||
"weaponSpecialSpring2025RogueNotes": "Mit einem Schwinger kannst du jedes Hindernis eliminieren, das deinen Zielen im Weg steht. Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"weaponSpecialSpring2025MageNotes": "Mit einem Schwinger kannst du Elementarmagie nutzen, um deine Umgebung zu kontrollieren. Nutze den Vorteil und spring vorwärts! Erhöht Intelligenz um <%= int %> und Wahrnehmung um <%= per %>. Limitierte Ausgabe Frühlingsausrüstung 2025.",
|
||||
"weaponSpecialSpring2025MageText": "Fangschrecken Stab",
|
||||
"armorSpecialSpring2025WarriorText": "Sonnenschein Rüstung",
|
||||
"armorSpecialSpring2025WarriorNotes": "Diese atemberaubende Rüstung hat Farben, die du inmitten eines sonnigen Frühlingstages am Himmel sehen kannst. Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe Frühlingsausrüstung 2025.",
|
||||
"armorSpecialSpring2025HealerText": "Plumeria Robe",
|
||||
"armorSpecialSpring2025HealerNotes": "Diese atemberaubende Robe enthält Plumeria Blütenblätter, die weich und raschelig sind. Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"weaponSpecialSpring2025HealerText": "Plumeria Hirtenstab",
|
||||
"weaponSpecialSpring2025HealerNotes": "Mit einem Schwinger kannst du Bestäuber an deine Seite rufen, um dir bei deinen Abenteuern zu helfen. Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"armorSpecialSpring2025RogueText": "Kristallnadel-Umhang",
|
||||
"armorSpecialSpring2025RogueNotes": "Dieser atemberaubende Umhang enthält extra Kristalle mit speziellen, geheimen Kräften, von denen nur du weißt. Erhöht Wahrnehmung um <%= per %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"armorSpecialSpring2025MageText": "Fangschrecken Uniform",
|
||||
"armorSpecialSpring2025MageNotes": "Diese atemberaubende Uniform enthält auffällige Farben, lässt dich aber trotzdem heimlich an deine schwierigsten Aufgaben anpirschen. Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"headSpecialSpring2025WarriorText": "Sonnenschein Helm",
|
||||
"headSpecialSpring2025WarriorNotes": "Der Kamm auf diesem Helm erinnert an den Lauf der Sonne, sieht aber auch aus wie eine Bürste, die du für den Frühjahrsputz nutzen könntest. Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"headSpecialSpring2025RogueText": "Kristallnadel-Hut",
|
||||
"headSpecialSpring2025RogueNotes": "Die Kristalle in diesem Hut ermuntern zu produktiver Energie und leuchten außerdem hell, damit du zu jeder Tages- und Nachtzeit arbeiten kannst. Erhöht Wahrnehmung um <%= per %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"headSpecialSpring2025HealerText": "Plumeria Kopfschmuck",
|
||||
"headSpecialSpring2025HealerNotes": "Diese Blume symbolisiert Geburt, Liebe und Neubeginn! Sie verbreitet auch einen schönen Duft, den du genießen kannst, während du an deinen Aufgaben arbeitest. Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"headSpecialSpring2025MageText": "Fangschrecken Maske",
|
||||
"headSpecialSpring2025MageNotes": "Die Fangschrecke ist bekannt dafür, sich zu tarnen oder sich langsam zu bewegen. Wähle eine Taktik zu Hilfe für jedes einzelne Ziel, sei aber gewiss, daß du Taktiken jederzeit ändern kannst, wenn du musst. Erhöht Wahrnehmung um <%= per %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"headMystery202503Text": "Jade Juggernaut Haar",
|
||||
"headMystery202503Notes": "Diese grüne Frisur passt perfekt zu einem tapferen Krieger und Verteidiger des Planeten. Gewährt keinen Attributbonus. März 2025 Abonnentengegenstand.",
|
||||
"armorArmoireSpringPetalYukataText": "Frühlingsblüten Yukata",
|
||||
"armorArmoireSpringPetalYukataNotes": "Diesen Yukata kann man perfekt zum Feiern des kommenden Frühlings anziehen. Stell sicher, daß du für ein Foto neben Kirschblüten posierst. Erhöht Ausdauer und Stärke um jeweils <%= attrs %> . Verzauberter Schrank: Frühlingsblüten Set (Gegenstand 1 von 2).",
|
||||
"shieldSpecialSpring2025RogueText": "Kristallspitzen-Flegel",
|
||||
"shieldSpecialSpring2025RogueNotes": "Du kannst den Kristall nutzen, um eine produktive Zukunft für dich weiszusagen. Nutze die Gelegenheit und spring vorwärts! Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"shieldSpecialSpring2025HealerText": "Plumeria Schild",
|
||||
"shieldSpecialSpring2025HealerNotes": "Du kannst dieses spezielle Blütenblatt verwenden, um Güte zu sammeln oder um negative Gedanken wegzuschnipsen. Nutze die Gelegenheit und spring vorwärts! Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2025 Ausrüstung.",
|
||||
"shieldSpecialSpring2025WarriorText": "Sonnenstrahl Schild",
|
||||
"shieldSpecialSpring2025WarriorNotes": "Du kannst deine Gegner für den Moment blenden, wenn die Sonne diesen Schild genau richtig trifft. Nutze den Vorteil und spring vorwärts! Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2025 Frühlingsausrüstung.",
|
||||
"shieldArmoireSpringPetalUchiwaNotes": "Dieser tragbare Fächer mit schönem Blütenmuster bewirkt eine leichte Brise nur für dich, wenn das Wetter wärmer wird. Erhöht Intelligenz und Wahrnehmung um jeweils <%= attrs %>. Verzauberter Schrank: Frühlingsblüten Set (Gegenstand 2 von 2).",
|
||||
"shieldArmoireSpringPetalUchiwaText": "Frühlingsblütenfächer",
|
||||
"eyewearMystery202503Text": "Jade Juggernaut Augen",
|
||||
"eyewearMystery202503Notes": "Dieser stechende Blick wird jeden Kämpfer, der es wagt, dich herauszufordern, in Panik versetzen! Gewährt keinen Attributbonus. März 2025 Abonnentengegenstand.",
|
||||
"armorMystery202504Text": "Scheues Yeti Rüstung",
|
||||
"armorMystery202504Notes": "Abscheulich? Eher anbetungswürdig! Gewährt keinen Attributbonus. April 2025 Abonnentengegenstand.",
|
||||
"armorArmoireSillyOrangeTuxedoText": "Alberner Orangen-Smoking",
|
||||
"armorArmoireSillyOrangeTuxedoNotes": "Dein eigener persönlicher Anzug des Tages. Erhöht Ausdauer um <%= con %>. Verzauberter Schrank: Lächerlicher Smoking Set (Gegenstand 1 von 2).",
|
||||
"armorArmoireSillierBlueTuxedoNotes": "Verbreite Stimmung in diesem einzigartigen Outfit. Erhöht Stärke um <%= str %>. Verzauberter Schrank: Noch Lächerlicherer Smoking Set (Gegenstand 1 von 2).",
|
||||
"armorArmoireSillierBlueTuxedoText": "Noch Lächerlicherer Blauer Smoking",
|
||||
"headMystery202504Text": "Scheues Yeti Kutte",
|
||||
"headArmoireSillyOrangeTophatText": "Alberner Orangen-Zylinder",
|
||||
"headArmoireSillyOrangeTophatNotes": "Passt gut zu einem Kürbiskuchen-Haarschnitt. Erhöht Stärke und Ausdauer um jeweils <%= attrs %>. Verzauberter Schrank: Lächerlicher Smoking Set (Gegenstand 2 von 2).",
|
||||
"headArmoireSillierBlueTophatText": "Noch Lächerlicherer Blauer Zylinder",
|
||||
"headMystery202504Notes": "Trage diese mysteriöse Visage, um unentdeckt unter den oskursten Fabelwesen der Welt zu verweilen. Gewährt keinen Attributbonus. April 2025 Abonnentengegenstand.",
|
||||
"headArmoireSillierBlueTophatNotes": "Etwas Klasse, etwas Raffinesse. Erhöht Stärke und Ausdauer um jeweils <%= attrs %>. Verzauberter Schrank: Noch Lächerlicherer Smoking Set (Gegenstand 2 von 2)."
|
||||
}
|
||||
|
||||
@@ -238,5 +238,8 @@
|
||||
"mutePlayer": "Stumm",
|
||||
"skipExternalLinkModal": "Halte STRG (Windows) oder Command (Mac) beim Anklicken eines Links, um dieses Modal zu überspringen.",
|
||||
"shadowMute": "Unsichtbare Stummschaltung",
|
||||
"titleCustomizations": "Individualisierungen"
|
||||
"titleCustomizations": "Individualisierungen",
|
||||
"targetUserNotExist": "Zielbenutzer: '<%= userName %>' existiert nicht.",
|
||||
"newMessage": "Neue Nachricht",
|
||||
"rememberToBeKind": "Bitte sei freundlich, respektvoll, und folge den <a href='/static/community-guidelines' target='_blank'>Community-Richtlinien</a>."
|
||||
}
|
||||
|
||||
@@ -428,7 +428,5 @@
|
||||
"readyToUpgrade": "Bereit zum Aufrüsten?",
|
||||
"interestedLearningMore": "Willst du mehr erfahren?",
|
||||
"checkGroupPlanFAQ": "Schau in die <a href='/static/faq#what-is-group-plan'>Gruppenpläne FAQ</a> um herauszufinden, wie du deine gemeinsamen Aufgaben optimal nutzen kannst.",
|
||||
"copyMessageAsToDo": "Nachricht als To-Do übernehmen",
|
||||
"copyAsTodo": "Als To-Do übernehmen",
|
||||
"messageAddedAsToDo": "Nachricht als To-Do übernommen."
|
||||
"messageCopiedToClipboard": "Nachricht in Zwischenablage kopiert."
|
||||
}
|
||||
|
||||
@@ -270,5 +270,9 @@
|
||||
"winter2025StringLightsHealerSet": "Lichterketten Heiler Set",
|
||||
"winter2025SnowRogueSet": "Schneeschurken Set",
|
||||
"winter2025MooseWarriorSet": "Elchkrieger Set",
|
||||
"winter2025AuroraMageSet": "Aurora Magier Set"
|
||||
"winter2025AuroraMageSet": "Aurora Magier Set",
|
||||
"spring2025PlumeriaHealerSet": "Plumeria Heiler Set",
|
||||
"spring2025MantisMageSet": "Fangschrecken Magier Set",
|
||||
"spring2025SunshineWarriorSet": "Sonnenschein Krieger Set",
|
||||
"spring2025CrystalPointRogueSet": "Kristallspitzen Schurken Set"
|
||||
}
|
||||
|
||||
@@ -56,7 +56,5 @@
|
||||
"messageBattleGearUnEquipped": "Kampfausrüstung abgelegt.",
|
||||
"messageAllUnEquipped": "Alle Auswahlen aufgehoben.",
|
||||
"messageBackgroundUnEquipped": "Hintergrundauswahl aufgehoben.",
|
||||
"featureRetired": "Diese Funktion wird nicht mehr unterstützt.",
|
||||
"beginningOfConversation": "Dies ist der Beginn deiner Konversation mit <%= userName %>.",
|
||||
"beginningOfConversationReminder": "Denke daran, nett und respektvoll zu sein und den Community-Richtlinien zu folgen!"
|
||||
"featureRetired": "Diese Funktion wird nicht mehr unterstützt."
|
||||
}
|
||||
|
||||
@@ -819,5 +819,17 @@
|
||||
"questCatRageTitle": "Wütendes Klopfen",
|
||||
"questCatRageDescription": "Diese Leiste füllt sich, wenn du deine Tagesaufgaben nicht erledigst. Wenn sie voll ist, nimmt der Schnurrende Verwirrer einen Teil der MP deiner Party weg!",
|
||||
"questCatRageEffect": "Der Schnurrende Verwirrer stößt die magischen Gegenstände, die du gesammelt hast, vom Tisch! Die MP der Party werden reduziert!",
|
||||
"questCatCompletion": "Zum Glück hast du alles aufgefangen, was die zudringliche Katze vom Tisch geworfen hat. Als du auf dem Boden sitzt, bemerkst du ein helles Leuchten, das von den Gegenständen vor dir ausgeht. Wenn du nach oben schaust, reagieren die Gegenstände auf dem Tisch auch! Sie auf verschiedene Höhen zu stellen, könnte ein Durchbruch in deinen Forschungen sein! <br><br>„Weißt du, am Ende hast du mir doch geholfen. Ich glaube, ich habe einfach einen neuen Blick auf meine Aufgabe gebraucht, um mich aus der Patsche zu befreien. Ich wünschte allerdings, du hättest mich ein bisschen vorgewarnt, bevor du anfängst, die Dinge herumzuschieben“, sagst du zu der Katze und streichelst sie sanft. <br><br>„Das ist ein sehr vernünftiges Anliegen, bitte nimm das als meine Entschuldigung“, schnurrt sie und schiebt dir ein paar lustig aussehende Eier zu. „Es freut mich, dass ich dir helfen konnte, die Dinge aus einer anderen Schnurrspektive zu sehen.“"
|
||||
"questCatCompletion": "Zum Glück hast du alles aufgefangen, was die zudringliche Katze vom Tisch geworfen hat. Als du auf dem Boden sitzt, bemerkst du ein helles Leuchten, das von den Gegenständen vor dir ausgeht. Wenn du nach oben schaust, reagieren die Gegenstände auf dem Tisch auch! Sie auf verschiedene Höhen zu stellen, könnte ein Durchbruch in deinen Forschungen sein! <br><br>„Weißt du, am Ende hast du mir doch geholfen. Ich glaube, ich habe einfach einen neuen Blick auf meine Aufgabe gebraucht, um mich aus der Patsche zu befreien. Ich wünschte allerdings, du hättest mich ein bisschen vorgewarnt, bevor du anfängst, die Dinge herumzuschieben“, sagst du zu der Katze und streichelst sie sanft. <br><br>„Das ist ein sehr vernünftiges Anliegen, bitte nimm das als meine Entschuldigung“, schnurrt sie und schiebt dir ein paar lustig aussehende Eier zu. „Es freut mich, dass ich dir helfen konnte, die Dinge aus einer anderen Schnurrspektive zu sehen.“",
|
||||
"questOtterText": "Der Perfide Verschwörer!",
|
||||
"questOtterDropOtterEgg": "Otter (Ei)",
|
||||
"questOtterUnlockText": "Schält Otter Eier zum Kauf im Marktplatz frei",
|
||||
"questOtterBoss": "Der Verschwörer",
|
||||
"questOtterRageEffect": "Der Verschwörer wirft Teile deiner To-Do Liste in die Luft! Der Boss erhält 30% seiner Lebenspunkte zurück!",
|
||||
"questOtterRageDescription": "Dieser Balken füllt sich, wenn du deine Tagesaufgaben nicht erledigst. Wenn er voll ist, erhält Der Verschwörer einige Lebenspunkte zurück!",
|
||||
"questJadeText": "Ein Matter Unglücksbringer",
|
||||
"questJadeBoss": "Matter Unglücksbringer",
|
||||
"questJadeDropJadePotion": "Jade Schlüpfelixier",
|
||||
"questJadeUnlockText": "Schält Jade Schlüpfelixier zum Kauf im Marktplatz frei.",
|
||||
"questOtterRageTitle": "To-Do Abriß!",
|
||||
"questJadeNotes": "Du bist zu Hause und starrst auf den Stapel dreckigen Geschirrs in der Spüle. Auf den Haufen schmutziger Wäsche in einer wahllosen Ecke des Raums. Auf die leeren Tassen und Snackverpackungen um deinen Tisch herum...<br><br>Du seufzt. „Warum ist da immer noch mehr dreckiges Geschirr... Die Sauerei hört nie auf.“ Es ist so demotivierend. Du findest dich auf der Couch wieder, endlos durch die neuesten Trends scrollend. Wer weiß, wie lang du da warst...<br><br>Als du von deinem Telefon hochsiehst, ist alles grün. Dies ist nicht dein Wohnzimmer. Als du aufstehst, findest du dich an der Seite eines leuchtend grünen Berges wieder.<br><br>Bewegung in der Ferne erregt deine Aufmerksamkeit. Eine steinerne grüne Gestalt grunzt, während sie einen Fels das steinige Terrain hoch wälzt. Er macht einige Fortschritte, aber ein kleiner Ausrutscher seines Fußes lässt den glänzenden Fels zurück nach unten rollen, direkt auf dich zu!<br><br>Er entdeckt dich, als er zu dem Brocken Jade rennt, der auf dich zu poltert! „Du denkst also, der Abwasch ist übel?“ schreit die Gestalt, „Versuch das!“"
|
||||
}
|
||||
|
||||
@@ -260,5 +260,7 @@
|
||||
"subscribeAgainContinueHourglasses": "Erneuere Dein Abonnement, um weiterhin Mystische Sanduhren zu erhalten",
|
||||
"mysterySet202411": "Borstenkämpfer Set",
|
||||
"mysterySet202501": "Frostbinder-Set",
|
||||
"mysterySet202502": "Herzliches Harlekin-Set"
|
||||
"mysterySet202502": "Herzliches Harlekin-Set",
|
||||
"mysterySet202503": "Jade Juggernaut Set",
|
||||
"mysterySet202504": "Scheues Yeti Set"
|
||||
}
|
||||
|
||||
@@ -1015,6 +1015,14 @@
|
||||
"backgroundOldFashionedTeaShopText": "Old Fashioned Tea Shop",
|
||||
"backgroundOldFashionedTeaShopNotes": "Enjoy a cozy beverage in an Old Fashioned Tea Shop.",
|
||||
|
||||
"backgrounds032025": "SET 130: Released March 2025",
|
||||
"backgroundMountainSceneWithBlossomsText": "Mountain Scene with Blossoms",
|
||||
"backgroundMountainSceneWithBlossomsNotes": "Take in the lovely sights and scents of a Mountain Scene with Blossoms.",
|
||||
|
||||
"backgrounds0420205": "SET 131: Released April 2025",
|
||||
"backgroundGardenWithFlowerBedsText": "Garden with Flower Beds",
|
||||
"backgroundGardenWithFlowerBedsNotes": "Enjoy the blooms of spring in a Garden with Flower Beds.",
|
||||
|
||||
"timeTravelBackgrounds": "Steampunk Backgrounds",
|
||||
"backgroundAirshipText": "Airship",
|
||||
"backgroundAirshipNotes": "Become a sky sailor on board your very own Airship.",
|
||||
|
||||
@@ -275,6 +275,14 @@
|
||||
"questEggCatMountText": "Cat",
|
||||
"questEggCatAdjective": "a mischievous",
|
||||
|
||||
"questEggOtterText": "Otter",
|
||||
"questEggOtterMountText": "Otter",
|
||||
"questEggOtterAdjective": "a perfidious",
|
||||
|
||||
"questEggAlpacaText": "Alpaca",
|
||||
"questEggAlpacaMountText": "Alpaca",
|
||||
"questEggAlpacaAdjective": "an overpacked",
|
||||
|
||||
"eggNotes": "Find a hatching potion to pour on this egg, and it will hatch into <%= eggAdjective(locale) %> <%= eggText(locale) %>.",
|
||||
|
||||
"hatchingPotionBase": "Base",
|
||||
@@ -340,9 +348,14 @@
|
||||
"hatchingPotionFungi": "Fungi",
|
||||
"hatchingPotionKoi": "Koi",
|
||||
"hatchingPotionGingerbread": "Gingerbread",
|
||||
"hatchingPotionJade": "Jade",
|
||||
"hatchingPotionBalloon": "Balloon",
|
||||
"hatchingPotionCryptid": "Cryptid",
|
||||
|
||||
"hatchingPotionNotes": "Pour this on an egg, and it will hatch as a <%= potText(locale) %> Pet.",
|
||||
"premiumPotionUnlimitedNotes": "Not usable on Quest Pet eggs.",
|
||||
"wackyPotionNotes": "Pour this on an egg, and it will hatch as a Wacky <%= potText(locale) %> Pet.",
|
||||
"wackyPotionAddlNotes": "Cannot be raised to Mounts or used on Quest Pet eggs.",
|
||||
|
||||
"foodMeat": "Meat",
|
||||
"foodMeatThe": "the Meat",
|
||||
|
||||
@@ -475,7 +475,7 @@
|
||||
"weaponSpecialSpring2023WarriorText": "Hummingbird Foil",
|
||||
"weaponSpecialSpring2023WarriorNotes": "En garde! Fend off foes from your flowers with this foil! Increases Strength by <%= str %>. Limited Edition 2023 Spring Gear.",
|
||||
"weaponSpecialSpring2023MageText": "Moonstone Magic",
|
||||
"weaponSpecialSpring2023MageNotes": "The greater their glow, the more potent is their power. Increases Intelligence by <%= int %>. Limited Edition 2023 Spring Gear.",
|
||||
"weaponSpecialSpring2023MageNotes": "The greater their glow, the more potent is their power. Increases Intelligence by <%= int %> and Perception by <%= per %>. Limited Edition 2023 Spring Gear.",
|
||||
"weaponSpecialSpring2023HealerText": "Lilium Pollen",
|
||||
"weaponSpecialSpring2023HealerNotes": "With a puff and a sparkle, you deploy new growth, joy, and color. Increases Intelligence by <%= int %>. Limited Edition 2023 Spring Gear.",
|
||||
|
||||
@@ -542,6 +542,15 @@
|
||||
"weaponSpecialWinter2025MageText": "Northern Lights Display",
|
||||
"weaponSpecialWinter2025MageNotes": "This stunning, colorful show provides the perfect backdrop! You’ll be unstoppable! Increases Intelligence by <%= int %> and Perception by <%= per %>. Limited Edition Winter 2024-2025 Gear.",
|
||||
|
||||
"weaponSpecialSpring2025WarriorText": "Sunshine Scimitar",
|
||||
"weaponSpecialSpring2025WarriorNotes": "With one slice, you can cut through flower stems to make a bouquet or right through obstacles to get your tasks accomplished. Increases Strength by <%= str %>. Limited Edition Spring 2025 Gear.",
|
||||
"weaponSpecialSpring2025RogueText": "Crystal Point Flail",
|
||||
"weaponSpecialSpring2025RogueNotes": "With one swing, you can eliminate any obstacles standing in the way of your goals. Increases Strength by <%= str %>. Limited Edition Spring 2025 Gear.",
|
||||
"weaponSpecialSpring2025HealerText": "Plumeria Crook",
|
||||
"weaponSpecialSpring2025HealerNotes": "With one wave, you can summon pollinators to your side to help you in your adventures. Increases Intelligence by <%= int %>. Limited Edition Spring 2025 Gear.",
|
||||
"weaponSpecialSpring2025MageText": "Mantis Staff",
|
||||
"weaponSpecialSpring2025MageNotes": "With one slash, you can use elemental magic to control the environment around you. Take advantage and spring forward! Increases Intelligence by <%= int %> and Perception by <%= per %>. Limited Edition Spring 2025 Gear.",
|
||||
|
||||
"weaponMystery201411Text": "Pitchfork of Feasting",
|
||||
"weaponMystery201411Notes": "Stab your enemies or dig in to your favorite foods - this versatile pitchfork does it all! Confers no benefit. November 2014 Subscriber Item.",
|
||||
"weaponMystery201502Text": "Shimmery Winged Staff of Love and Also Truth",
|
||||
@@ -1322,6 +1331,14 @@
|
||||
"armorSpecialWinter2025MageText": "Aurora Cloak",
|
||||
"armorSpecialWinter2025MageNotes": "Wonder, whimsy, enchantment, and splendor will fill your days when you dance in this cloak. Increases Intelligence by <%= int %>. Limited Edition Winter 2024-2025 Gear.",
|
||||
|
||||
"armorSpecialSpring2025WarriorText": "Sunshine Armor",
|
||||
"armorSpecialSpring2025WarriorNotes": "This stunning armor contains colors you might see in the sky in the middle of a sunny spring day. Increases Constitution by <%= con %>. Limited Edition Spring 2025 Gear.",
|
||||
"armorSpecialSpring2025RogueText": "Crystal Point Cloak",
|
||||
"armorSpecialSpring2025RogueNotes": "This stunning cloak contains extra crystals with special, secret powers only you know about. Increases Perception by <%= per %>. Limited Edition Spring 2025 Gear.",
|
||||
"armorSpecialSpring2025HealerText": "Plumeria Robes",
|
||||
"armorSpecialSpring2025HealerNotes": "This stunning robe contains plumeria petals that are soft and swishy. Increases Constitution by <%= con %>. Limited Edition Spring 2025 Gear.",
|
||||
"armorSpecialSpring2025MageText": "Mantis Uniform",
|
||||
"armorSpecialSpring2025MageNotes": "This stunning uniform contains flashy colors but also lets you stalk your hardest tasks with stealth. Increases Intelligence by <%= int %>. Limited Edition Spring 2025 Gear.",
|
||||
|
||||
"armorMystery201402Text": "Messenger Robes",
|
||||
"armorMystery201402Notes": "Shimmering and strong, these robes have many pockets to carry letters. Confers no benefit. February 2014 Subscriber Item.",
|
||||
@@ -1455,7 +1472,9 @@
|
||||
"armorMystery202412Notes": "A fun and fluffy look to keep you snug on a winter day. Confers no benefit. December 2024 Subscriber Item.",
|
||||
"armorMystery202502Text": "Heartfelt Harlequin Suit",
|
||||
"armorMystery202502Notes": "You’re full of kind-hearted jokes and japes from your ruffled collar to your gigantic shoes! Confers no benefit. February 2025 Subscriber Item.",
|
||||
|
||||
"armorMystery202504Text": "Elusive Yeti Armor",
|
||||
"armorMystery202504Notes": "Abominable? More like adorable! Confers no benefit. April 2025 Subscriber Item.",
|
||||
|
||||
"armorMystery301404Text": "Steampunk Suit",
|
||||
"armorMystery301404Notes": "Dapper and dashing, wot! Confers no benefit. February 3015 Subscriber Item.",
|
||||
"armorMystery301703Text": "Steampunk Peacock Gown",
|
||||
@@ -1683,6 +1702,12 @@
|
||||
"armorArmoireFestiveHelperOverallsNotes": "Durable and comfortable, these overalls are great for working, playing, and assisting others. Plus, it has pockets! Increases Constitution by <%= con %>. Enchanted Armoire: Festive Helper Set (Item 2 of 2)",
|
||||
"armorArmoireSnowyFluffTrimmedCoatText": "Snowy Fluff-Trimmed Coat",
|
||||
"armorArmoireSnowyFluffTrimmedCoatNotes": "As the first flakes fall around you, this coat will not only keep you toasty but also help you blend in perfectly with your snowy surroundings. Glide along the ice in style! Increases Strength and Intelligence by <%= attrs %> each. Enchanted Armoire: Snowy Trapper Hat Set (Item 2 of 2).",
|
||||
"armorArmoireSpringPetalYukataText": "Spring Petal Yukata",
|
||||
"armorArmoireSpringPetalYukataNotes": "This yukata is perfect to wear to celebrate the coming of spring. Be sure to pose by cherry blossoms for a photo. Increases Constitution and Strength by <%= attrs %> each. Enchanted Armoire: Spring Petal Set (Item 1 of 2).",
|
||||
"armorArmoireSillyOrangeTuxedoText": "Silly Orange Tuxedo",
|
||||
"armorArmoireSillyOrangeTuxedoNotes": "Your own personal suit du jour. Increases Constitution by <%= con %>. Enchanted Armoire: Silly Tuxedo Set (Item 1 of 2).",
|
||||
"armorArmoireSillierBlueTuxedoText": "Sillier Blue Tuxedo",
|
||||
"armorArmoireSillierBlueTuxedoNotes": "Put out the vibe in this unique outfit. Increases Strength by <%= str %>. Enchanted Armoire: Sillier Tuxedo Set (Item 1 of 2).",
|
||||
|
||||
"headgear": "helm",
|
||||
"headgearCapitalized": "Headgear",
|
||||
@@ -2199,6 +2224,15 @@
|
||||
"headSpecialWinter2025MageText": "Aurora Hat",
|
||||
"headSpecialWinter2025MageNotes": "More than just a fancy fascinator, this hat makes you look like the aurora borealis itself. Increases Perception by <%= per %>. Limited Edition Winter 2024-2025 Gear.",
|
||||
|
||||
"headSpecialSpring2025WarriorText": "Sunshine Helmet",
|
||||
"headSpecialSpring2025WarriorNotes": "The crest atop this shiny helmet resembles the path of the sun but also looks like a brush you could use for spring cleaning. Increases Strength by <%= str %>. Limited Edition Spring 2025 Gear.",
|
||||
"headSpecialSpring2025RogueText": "Crystal Point Hat",
|
||||
"headSpecialSpring2025RogueNotes": "The crystals in this hat encourage productive energy but also shine bright so you can work any time of day or night. Increases Perception by <%= per %>. Limited Edition Spring 2025 Gear.",
|
||||
"headSpecialSpring2025HealerText": "Plumeria Headdress",
|
||||
"headSpecialSpring2025HealerNotes": "This flower symbolizes birth, love, and new beginnings! It also provides a beautiful scent you can enjoy as you work on your tasks. Increases Intelligence by <%= int %>. Limited Edition Spring 2025 Gear.",
|
||||
"headSpecialSpring2025MageText": "Mantis Mask",
|
||||
"headSpecialSpring2025MageNotes": "The mantis is known for camouflaging or moving slowly. Choose one tactic to help with each goal but know you can always change tactics if you need to. Increases Perception by <%= per %>. Limited Edition Spring 2025 Gear.",
|
||||
|
||||
"headSpecialGaymerxText": "Rainbow Warrior Helm",
|
||||
"headSpecialGaymerxNotes": "In celebration of the GaymerX Conference, this special helmet is decorated with a radiant, colorful rainbow pattern! GaymerX is a game convention celebrating LGTBQ and gaming and is open to everyone.",
|
||||
|
||||
@@ -2376,6 +2410,10 @@
|
||||
"headMystery202501Notes": "This sparkling hat generates a light and festive flurry around you at all times. Confers no benefit. January 2025 Subscriber Item.",
|
||||
"headMystery202502Text": "Heartfelt Harlequin Hat",
|
||||
"headMystery202502Notes": "This jaunty little hat is sure to inspire joy in anyone who sees you! Confers no benefit. February 2025 Subscriber Item.",
|
||||
"headMystery202503Text": "Jade Juggernaut Hair",
|
||||
"headMystery202503Notes": "This verdant hairdo perfectly suits a brave warrior and defender of the planet. Confers no benefit. March 2025 Subscriber Item.",
|
||||
"headMystery202504Text": "Elusive Yeti Cowl",
|
||||
"headMystery202504Notes": "Wear this mysterious visage to dwell undetected among the world’s most obscure cyptids. Confers no benefit. April 2025 Subscriber Item.",
|
||||
|
||||
"headMystery301404Text": "Fancy Top Hat",
|
||||
"headMystery301404Notes": "A fancy top hat for the finest of gentlefolk! January 3015 Subscriber Item. Confers no benefit.",
|
||||
@@ -2585,7 +2623,11 @@
|
||||
"headArmoireSnowyTrapperHatText": "Snowy Trapper Hat",
|
||||
"headArmoireSnowyTrapperHatNotes": "Blue, frostbitten ears will be a thing of the past. Embrace cozy warmth in style! Increases Constitution and Perception by <%= attrs %> each. Enchanted Armoire: Snowy Trapper Hat Set (Item 1 of 2).",
|
||||
"headArmoireFancyFloralHatText": "Fancy Floral Hat",
|
||||
"headArmoireFancyFloralHatNotes": "Feast your eyes on this fancy hat full of fantastic flowers and frilly fastenings. Increases Intelligence by <%= int %>. Enchanted Armoire: Fancy Floral Accessories Set (Item 1 of 2).",
|
||||
"headArmoireFancyFloralHatNotes": "Feast your eyes on this fancy hat full of fantastic flowers and frilly fastenings. Increases Intelligence by <%= int %>. Enchanted Armoire: Fancy Floral Accessories Set (Item 1 of 2).",
|
||||
"headArmoireSillyOrangeTophatText": "Silly Orange Tophat",
|
||||
"headArmoireSillyOrangeTophatNotes": "Goes well with a pumpkin pie hair cut. Increases Strength and Constitution by <%= attrs %> each. Enchanted Armoire: Silly Tuxedo Set (Item 2 of 2).",
|
||||
"headArmoireSillierBlueTophatText": "Sillier Blue Tophat",
|
||||
"headArmoireSillierBlueTophatNotes": "A little class, a little sophistication. Increases Strength and Constitution by <%= attrs %> each. Enchanted Armoire: Sillier Tuxedo Set (Item 2 of 2).",
|
||||
|
||||
"offhand": "off-hand item",
|
||||
"offHandCapitalized": "Off-Hand Item",
|
||||
@@ -2879,6 +2921,13 @@
|
||||
"shieldSpecialWinter2025HealerText": "The Perfect Gift",
|
||||
"shieldSpecialWinter2025HealerNotes": "The perfect gift is just waiting to be opened. What could be inside? Increases Constitution by <%= con %>. Limited Edition Winter 2024-2025 Gear.",
|
||||
|
||||
"shieldSpecialSpring2025WarriorText": "Sunburst Shield",
|
||||
"shieldSpecialSpring2025WarriorNotes": "You can momentarily blind your enemies when sun hits this shield just right. Take advantage and spring forward! Increases Constitution by <%= con %>. Limited Edition Spring 2025 Gear.",
|
||||
"shieldSpecialSpring2025RogueText": "Crystal Point Flail",
|
||||
"shieldSpecialSpring2025RogueNotes": "You can use the crystal to divine a productive future for yourself. Take advantage and spring forward! Increases Strength by <%= str %>. Limited Edition Spring 2025 Gear.",
|
||||
"shieldSpecialSpring2025HealerText": "Plumeria Shield",
|
||||
"shieldSpecialSpring2025HealerNotes": "You can use this special petal to gather goodness or flick negative thoughts away. Take advantage and spring forward! Increases Constitution by <%= con %>. Limited Edition 2025 Gear.",
|
||||
|
||||
"shieldMystery201601Text": "Resolution Slayer",
|
||||
"shieldMystery201601Notes": "This blade can be used to parry away all distractions. Confers no benefit. January 2016 Subscriber Item.",
|
||||
"shieldMystery201701Text": "Time-Freezer Shield",
|
||||
@@ -3073,6 +3122,8 @@
|
||||
"shieldArmoireSafetyFlashlightNotes": "Wait, did you hear that noise? Quick! Shine your flashlight into the shadows over there. Hmmm. Looks like it was just the wind. Or was it…? Increases Constitution by <%= con %>. Enchanted Armoire: Fright Night Set (Item 1 of 2)",
|
||||
"shieldArmoireFancyFloralFanText": "Fancy Floral Fan",
|
||||
"shieldArmoireFancyFloralFanNotes": "Finish your fancy look with this first-rate fan made of fabulous floral fabric. Increases Perception by <%= per %>. Enchanted Armoire: Fancy Floral Accessories Set (Item 2 of 2).",
|
||||
"shieldArmoireSpringPetalUchiwaText": "Spring Petal Uchiwa",
|
||||
"shieldArmoireSpringPetalUchiwaNotes": "This handheld fan featuring a beautiful petal pattern creates a small breeze just for you as the weather warms. Increases Intelligence and Perception by <%= attrs %> each. Enchanted Armoire: Spring Petal Set (Item 2 of 2).",
|
||||
|
||||
"back": "Back Accessory",
|
||||
"backBase0Text": "No Back Accessory",
|
||||
@@ -3499,6 +3550,8 @@
|
||||
"eyewearMystery202312Notes": "No need to worry, these icy blues will help you see through the cold and dark season to the warmth of months ahead. Confers no benefit. December 2023 Subscriber Item.",
|
||||
"eyewearMystery202406Text": "Phantom Buccaneer’s Mask",
|
||||
"eyewearMystery202406Notes": "Try to avoid having this pulled off by a gang of meddling kids and their talking dog. Confers no benefit. June 2024 Subscriber Item.",
|
||||
"eyewearMystery202503Text": "Jade Juggernaut Eyes",
|
||||
"eyewearMystery202503Notes": "This piercing gaze will strike terror into any fighter who dares to challenge you! Confers no benefit. March 2025 Subscriber Item.",
|
||||
|
||||
|
||||
"eyewearMystery301404Text": "Eyewear Goggles",
|
||||
|
||||
@@ -207,7 +207,8 @@
|
||||
"dismissAll": "Dismiss All",
|
||||
"messages": "Messages",
|
||||
"emptyMessagesLine1": "You don't have any messages",
|
||||
"emptyMessagesLine2": "You can send a new message to a user by visiting their profile and clicking the \"Message\" button.",
|
||||
"emptyMessagesLine2": "Send a message to start a conversation with your Party members or another Habitica player",
|
||||
"newMessage": "New Message",
|
||||
"userSentMessage": "<span class=\"notification-bold\"><%- user %></span> sent you a message",
|
||||
"letsgo": "Let's Go!",
|
||||
"selected": "Selected",
|
||||
@@ -238,5 +239,7 @@
|
||||
"submitQuestion": "Submit Question",
|
||||
"whyReportingPlayer": "Why are you reporting this player?",
|
||||
"whyReportingPlayerPlaceholder": "Reason for report",
|
||||
"playerReportModalBody": "You should only report a player who violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Submitting a false report is a violation of Habitica’s Community Guidelines."
|
||||
"playerReportModalBody": "You should only report a player who violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Submitting a false report is a violation of Habitica’s Community Guidelines.",
|
||||
"targetUserNotExist": "Target User: '<%= userName %>' does not exist.",
|
||||
"rememberToBeKind": "Please remember to be kind, respectful, and follow the <a href='/static/community-guidelines' target='_blank'>Community Guidelines</a>."
|
||||
}
|
||||
|
||||
@@ -114,9 +114,7 @@
|
||||
"whyReportingPostPlaceholder": "Reason for report",
|
||||
"optional": "Optional",
|
||||
"needsTextPlaceholder": "Type your message here.",
|
||||
"copyMessageAsToDo": "Copy message as To Do",
|
||||
"copyAsTodo": "Copy as To Do",
|
||||
"messageAddedAsToDo": "Message copied as To Do.",
|
||||
"messageCopiedToClipboard": "Message copied to clipboard.",
|
||||
"leaderOnlyChallenges": "Only group leader can create challenges",
|
||||
"sendGift": "Send a Gift",
|
||||
"selectGift": "Select Gift",
|
||||
|
||||
@@ -226,7 +226,11 @@
|
||||
"winter2025MooseWarriorSet": "Moose Warrior Set",
|
||||
"winter2025AuroraMageSet": "Aurora Mage Set",
|
||||
"winter2025StringLightsHealerSet": "String Lights Healer Set",
|
||||
"winter2025SnowRogueSet": "Snow Rogue Set",
|
||||
"winter2025SnowRogueSet": "Snow Rogue Set",
|
||||
"spring2025SunshineWarriorSet": "Sunshine Warrior Set",
|
||||
"spring2025CrystalPointRogueSet": "Crystal Point Rogue Set",
|
||||
"spring2025PlumeriaHealerSet": "Plumeria Healer Set",
|
||||
"spring2025MantisMageSet": "Mantis Mage Set",
|
||||
"winterPromoGiftHeader": "GIFT A SUBSCRIPTION, GET ONE FREE!",
|
||||
"winterPromoGiftDetails1": "Until January 6th only, when you gift somebody a subscription, you get the same subscription for yourself for free!",
|
||||
"winterPromoGiftDetails2": "Please note that if you or your gift recipient already have a recurring subscription, the gifted subscription will only start after that subscription is cancelled or has expired. Thanks so much for your support! <3",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user