Compare commits

..

81 Commits

Author SHA1 Message Date
Phillip Thelen 9aed479296 add semicolon 2026-03-25 11:54:56 +01:00
Phillip Thelen 47c156d9b1 add key prefix 2026-03-25 11:51:51 +01:00
Phillip Thelen e6418e4356 add space 2026-03-25 11:48:03 +01:00
Phillip Thelen 3c23989e99 use bullmq directly to schedule jobs 2026-03-25 11:37:02 +01:00
Phillip Thelen 2d1f341256 refactor redis setup into own file and use ioredis 2026-03-25 11:35:35 +01:00
Kalista Payne 44adfd611a fix(lint): no-undef 2026-03-23 19:31:12 -05:00
Kalista Payne ab50c41287 fix(tests): remove tests
These can potentially be tested in the worker's suite? They target functionality that the group leave route handles within the deletion flow
2026-03-23 17:18:01 -05:00
Kalista Payne c43abe82fe Revert "fix(deletion): handle group leave logic on app server still"
This reverts commit 9db541f4c3.
2026-03-23 08:51:53 -05:00
Kalista Payne ac0b4a324f fix(deletion): remove orphaned chat messages 2026-03-20 11:17:30 -05:00
Kalista Payne efa0a325a2 fix(text): don't break to new paragraph about Gems 2026-03-20 11:17:30 -05:00
Kalista Payne bc970d33ac fix(deletion): update delete/feedback form copy 2026-03-20 11:17:30 -05:00
Kalista Payne 09e432cf32 fix(test): adapt test for worker flow 2026-03-20 11:17:30 -05:00
Kalista Payne 40aa2e214d fix(import): bracket syntax 2026-03-20 11:17:30 -05:00
Kalista Payne 9f563b741d fix(lint): unused import 2026-03-20 11:17:30 -05:00
Kalista Payne 9db541f4c3 fix(deletion): handle group leave logic on app server still 2026-03-20 11:17:30 -05:00
Kalista Payne ce4a20e3d8 WIP(delete): remove business logic from controller 2026-03-20 11:17:30 -05:00
Kalista Payne cc7683a871 chore(git): update submodule 2026-03-19 18:09:22 -05:00
Kalista Payne 31b2781333 Squashed commit of the following:
commit 866f074a15
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 15:51:18 2026 -0500

    fix(quests): remove backticks from text

commit d06fc2825e
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 13:16:32 2026 -0500

    fix(background): add missing data

commit 55156c5f80
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 13:00:43 2026 -0500

    fix(lint): max-len

commit db88092acf
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 12:56:30 2026 -0500

    fix(customization): show event backgrounds for AF

commit d6fd1ce7fa
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Mar 17 15:55:53 2026 +0100

    set release date

commit b876d8c789
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Mar 17 11:40:17 2026 +0100

    Improve swap handling

commit f75a4d0147
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Mar 17 11:22:42 2026 +0100

    use correct name for bear sprites

commit 195db1b132
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Mar 16 23:01:35 2026 +0100

    more fix

commit a42d3f08d7
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Mar 16 18:43:21 2026 +0100

    fix test

commit 9c06299c34
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Mar 16 09:13:13 2026 +0100

    AF tweaks

commit 5465e23e67
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 11 19:43:01 2026 -0500

    fix(sprites): add missing gif redirects

commit 5721ecc6f2
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 16:34:26 2026 -0500

    chore(css): run sprites

commit 2184ff2e69
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 10:38:34 2026 -0500

    fix(test): date

commit d684a7297c
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 10:32:04 2026 -0500

    fix(test): update for 2026, also more lint

commit 82e7947fd7
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 10:22:05 2026 -0500

    fix(event): lint and missing pieces

commit 96818b2d77
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Mar 9 20:11:22 2026 -0500

    feat(event): finished Alien build

commit 4c9763b676
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Mar 6 16:30:36 2026 -0600

    wip(event): April Fools 2026 build

commit 2f16a016e6
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Feb 4 14:16:50 2026 +0100

    add april fools tests

commit cd1d926c98
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Feb 4 14:09:16 2026 +0100

    make april fools cycle through

commit d8a4216c41
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 14:41:26 2026 +0100

    fix lint

commit 8265a15923
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 12:34:46 2026 +0100

    name key more generic

commit 9c7bde8ad5
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 12:26:43 2026 +0100

    right date for april fools

commit c2b92c6311
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 12:26:19 2026 +0100

    rework how april fools works
2026-03-19 15:09:32 -05:00
Kalista Payne d37d3bc5ac 5.46.4 2026-03-17 14:51:06 -05:00
Weblate ef3a28791e Translated using Weblate (Dutch)
Currently translated at 72.8% (2586 of 3551 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 95.0% (19 of 20 strings)

Translated using Weblate (Dutch)

Currently translated at 63.6% (161 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 70.0% (14 of 20 strings)

Translated using Weblate (Russian)

Currently translated at 98.5% (927 of 941 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (20 of 20 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Korean)

Currently translated at 43.3% (82 of 189 strings)

Translated using Weblate (Korean)

Currently translated at 33.8% (64 of 189 strings)

Co-authored-by: Alexander Tschigir <tchi.gugl@gmail.com>
Co-authored-by: Jildau Bras <jildaubras@gmail.com>
Co-authored-by: Serhii <serzh.photograf@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Tijl Casteleyn <casteleyntijl@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: 성명 <phjk2536@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/front/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/uk/
Translation: Habitica/Backgrounds
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Rebirth
Translation: Habitica/Settings
2026-03-17 10:33:27 +01:00
Kalista Payne c3c2607bca Remove join retired public guild loophole (#15626)
* fix(guilds): remove join retired public guild loophole

* fix(groups): adjust test expectations
and move some business logic back out of the controller

* test(challenges): add cases related to public Guilds, Tavern and otherwise

* fix(tests): more erroneously public test guilds, lint

* fix(tests): still more setup tweaks

* fix(lint): whitespace

* fix(tests): couple more adjustments

* fix(test): last challenge issue??
2026-03-12 19:15:00 -05:00
Tanmay Nalawade 7a6d64f158 Duplicate tags Bug solved (#15615)
* added hasExactMatch function

* changed the handleSubmit logic to check for match

* changed the conditional in class for dropdown

* added hasExactMatch conditional to display the AddTag text

* one more conditional to check if string is empty

* changed styling to display hidden text of "Press Enter to add tag"

* lint error fixed

* fix(tags): don't allow same tag spam within add session

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
2026-03-12 19:09:30 -05:00
Kalista Payne 836e63246d fix(emails): correct variable name in group plan invites 2026-03-12 18:58:49 -05:00
Kalista Payne 61585b2549 5.46.3 2026-03-12 16:51:19 -05:00
Corban Villa 07275bd522 fix(security): don't reuse IV for cryptography 2026-03-12 14:53:30 -05:00
Fiz 74fc543ef2 Orb of Rebirth Updates (Count usage) (#15629)
* Update orb of rebirth to count every usage

* Orb of rebirth modal UI updates

* Fix rebirth modal showing again after page refresh

* remove unused MAX_LEVEL

* scale orb of rebirth on modal

* UI & wording updates for Orb of Rebirth

* UI & wording updates for Orb of Rebirth cont.

* Orb of rebirth UI tweaks

* Orb of rebirth UI tweaks

* Orb of Rebirth modal UI tweak

* Extend modal waves

* Continued Orb of Rebirth UI Updates

* Orb of rebirth margin tweak

* rebirth-orb asset
2026-03-12 14:51:48 -05:00
Kalista Payne 8c90e5472b 5.46.2 2026-03-10 12:02:09 -05:00
Kalista Payne c8d9ba6c8e chore(git): update submodule 2026-03-10 12:02:06 -05:00
Weblate 1675c2749b Merge branch 'origin/develop' into Weblate. 2026-03-10 17:58:10 +01:00
Rohithgowda K e85a2bae14 fix: prevent duplicate streak achievement notifications (#13325) (#15550)
* fix: prevent duplicate streak achievement notifications (#13325)

* fix(lint): whitespace
also remove unnecessary file and comments

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-03-10 10:03:28 -05:00
Weblate 3355500fba Translated using Weblate (French)
Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.2% (277 of 279 strings)

Translated using Weblate (Czech)

Currently translated at 78.5% (739 of 941 strings)

Translated using Weblate (Japanese)

Currently translated at 99.6% (278 of 279 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Portuguese)

Currently translated at 68.7% (189 of 275 strings)

Translated using Weblate (Portuguese)

Currently translated at 86.6% (383 of 442 strings)

Translated using Weblate (Portuguese)

Currently translated at 59.5% (2115 of 3551 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (442 of 442 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.1% (269 of 292 strings)

Translated using Weblate (Portuguese)

Currently translated at 91.4% (267 of 292 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.0% (438 of 442 strings)

Translated using Weblate (Portuguese)

Currently translated at 59.4% (2112 of 3551 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Korean)

Currently translated at 99.2% (934 of 941 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (Portuguese)

Currently translated at 63.0% (184 of 292 strings)

Translated using Weblate (Portuguese)

Currently translated at 46.2% (117 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 99.0% (932 of 941 strings)

Co-authored-by: Anderson Ferreira <freiitas.dev@gmail.com>
Co-authored-by: Begümay Çınar <begumay@proton.me>
Co-authored-by: Duggu Ghosh <duggu52d@gmail.com>
Co-authored-by: Isabela de França <ifranceg@gmail.com>
Co-authored-by: Kim Sihyung <kimsihyung@u.nus.edu>
Co-authored-by: Matej Boura <B.Matej@email.cz>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Translatlantic Translation <translatlanticom@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/zh_Hans/
Translation: Habitica/Backgrounds
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Settings
Translation: Habitica/Subscriber
2026-03-10 12:48:18 +01:00
Kalista Payne 486f15df0f fix(news): allow X dismiss for everyone 2026-03-09 15:00:47 -05:00
Kalista Payne f0b6b5611c feat(news): allow quick dismiss for admins 2026-03-06 19:07:48 -06:00
Kalista Payne 7e45c79714 chore(npm): update lockfiles 2026-03-06 11:58:02 -06:00
Kalista Payne 8da6065355 fix(pins): don't erase pinned quest potions 2026-03-06 11:22:29 -06:00
Kalista Payne a212363bda 5.46.1 2026-03-05 14:59:58 -06:00
Weblate 2e19e73b9e Translated using Weblate (Dutch)
Currently translated at 72.7% (2585 of 3551 strings)

Translated using Weblate (Japanese)

Currently translated at 97.6% (3466 of 3551 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (13 of 13 strings)

Translated using Weblate (Czech)

Currently translated at 13.0% (33 of 253 strings)

Translated using Weblate (Czech)

Currently translated at 98.2% (164 of 167 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (442 of 442 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (3551 of 3551 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.4% (3497 of 3551 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 50.6% (148 of 292 strings)

Translated using Weblate (German)

Currently translated at 98.1% (434 of 442 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3551 of 3551 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 99.8% (940 of 941 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.7% (441 of 442 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Russian)

Currently translated at 74.7% (189 of 253 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 82.1% (708 of 862 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 82.1% (708 of 862 strings)

Translated using Weblate (German)

Currently translated at 92.8% (271 of 292 strings)

Translated using Weblate (French)

Currently translated at 100.0% (442 of 442 strings)

Translated using Weblate (German)

Currently translated at 99.5% (937 of 941 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (French)

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (French)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Ukrainian)

Currently translated at 80.4% (152 of 189 strings)

Translated using Weblate (Ukrainian)

Currently translated at 54.5% (138 of 253 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Turkish)

Currently translated at 39.1% (99 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 35.5% (90 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 95.0% (192 of 202 strings)

Translated using Weblate (French)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 75.7% (653 of 862 strings)

Translated using Weblate (Turkish)

Currently translated at 75.7% (653 of 862 strings)

Translated using Weblate (Turkish)

Currently translated at 33.9% (86 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 95.3% (410 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 94.8% (408 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Turkish)

Currently translated at 94.6% (407 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 73.4% (2567 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 82.3% (354 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.3% (111 of 114 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 67.1% (2349 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 49.4% (1731 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 49.4% (1731 of 3497 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 86.2% (238 of 276 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.3% (111 of 114 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 67.1% (2349 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Dutch)

Currently translated at 88.8% (382 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 88.6% (381 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Dutch)

Currently translated at 88.3% (380 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 73.1% (2557 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Dutch)

Currently translated at 58.8% (149 of 253 strings)

Translated using Weblate (Japanese)

Currently translated at 98.6% (143 of 145 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Dutch)

Currently translated at 96.9% (127 of 131 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Dutch)

Currently translated at 76.0% (210 of 276 strings)

Translated using Weblate (Dutch)

Currently translated at 56.5% (143 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 85.5% (368 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (German)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (189 of 189 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Portuguese)

Currently translated at 79.3% (150 of 189 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 96.4% (110 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 95.9% (894 of 932 strings)

Translated using Weblate (Japanese)

Currently translated at 99.5% (201 of 202 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Hungarian)

Currently translated at 96.8% (183 of 189 strings)

Translated using Weblate (Hungarian)

Currently translated at 96.8% (245 of 253 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Hungarian)

Currently translated at 95.5% (193 of 202 strings)

Translated using Weblate (Hungarian)

Currently translated at 95.6% (109 of 114 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 98.6% (143 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 49.0% (1717 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 76.4% (2672 of 3497 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 51.6% (47 of 91 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 61.4% (169 of 275 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Swedish)

Currently translated at 59.6% (164 of 275 strings)

Translated using Weblate (Swedish)

Currently translated at 87.7% (115 of 131 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Swedish)

Currently translated at 87.5% (7 of 8 strings)

Translated using Weblate (Swedish)

Currently translated at 92.9% (106 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 91.4% (86 of 94 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Swedish)

Currently translated at 91.4% (86 of 94 strings)

Translated using Weblate (Japanese)

Currently translated at 98.1% (3434 of 3497 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Japanese)

Currently translated at 99.2% (251 of 253 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 92.4% (134 of 145 strings)

Translated using Weblate (Swedish)

Currently translated at 52.1% (144 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 63.5% (183 of 288 strings)

Translated using Weblate (Swedish)

Currently translated at 48.2% (139 of 288 strings)

Translated using Weblate (Turkish)

Currently translated at 47.2% (1653 of 3497 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 69.9% (2447 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Swedish)

Currently translated at 4.7% (12 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 91.0% (152 of 167 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 54.1% (137 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 93.5% (872 of 932 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 92.5% (187 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 52.5% (133 of 253 strings)

Translated using Weblate (German)

Currently translated at 100.0% (189 of 189 strings)

Translated using Weblate (Dutch)

Currently translated at 51.7% (131 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Dutch)

Currently translated at 91.8% (856 of 932 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Dutch)

Currently translated at 91.6% (854 of 932 strings)

Translated using Weblate (Portuguese)

Currently translated at 52.7% (1844 of 3497 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Swedish)

Currently translated at 51.0% (141 of 276 strings)

Translated using Weblate (Swedish)

Currently translated at 77.6% (73 of 94 strings)

Translated using Weblate (Swedish)

Currently translated at 89.9% (170 of 189 strings)

Translated using Weblate (Swedish)

Currently translated at 4.7% (12 of 253 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Swedish)

Currently translated at 58.5% (546 of 932 strings)

Translated using Weblate (Swedish)

Currently translated at 86.2% (163 of 189 strings)

Translated using Weblate (Swedish)

Currently translated at 85.7% (162 of 189 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (58 of 58 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (58 of 58 strings)

Translated using Weblate (Swedish)

Currently translated at 70.3% (133 of 189 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (56 of 56 strings)

Translated using Weblate (German)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 86.1% (248 of 288 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Swedish)

Currently translated at 59.2% (163 of 275 strings)

Translated using Weblate (Czech)

Currently translated at 9.0% (23 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Dutch)

Currently translated at 73.0% (2556 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Czech)

Currently translated at 8.6% (22 of 253 strings)

Translated using Weblate (Spanish)

Currently translated at 99.0% (200 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 79.8% (230 of 288 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (189 of 189 strings)

Translated using Weblate (Dutch)

Currently translated at 90.4% (843 of 932 strings)

Translated using Weblate (German)

Currently translated at 99.6% (3485 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (German)

Currently translated at 99.5% (3480 of 3497 strings)

Translated using Weblate (German)

Currently translated at 99.3% (3476 of 3497 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 98.0% (198 of 202 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Japanese)

Currently translated at 98.2% (112 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 96.4% (110 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 85.9% (801 of 932 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 99.3% (3475 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (German)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (German)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (German)

Currently translated at 99.6% (252 of 253 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 98.8% (250 of 253 strings)

Translated using Weblate (German)

Currently translated at 98.8% (250 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (German)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 84.0% (783 of 932 strings)

Translated using Weblate (German)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Dutch)

Currently translated at 49.4% (125 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 96.7% (88 of 91 strings)

Translated using Weblate (German)

Currently translated at 99.3% (3473 of 3497 strings)

Translated using Weblate (German)

Currently translated at 99.3% (3473 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.6% (3451 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 47.0% (119 of 253 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.3% (3440 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 72.5% (2536 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.6% (3380 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 72.2% (2528 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 99.1% (243 of 245 strings)

Translated using Weblate (Dutch)

Currently translated at 99.4% (188 of 189 strings)

Translated using Weblate (Dutch)

Currently translated at 47.0% (119 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 47.0% (119 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 47.0% (119 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (German)

Currently translated at 99.2% (3470 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 98.2% (112 of 114 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (German)

Currently translated at 96.5% (140 of 145 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 82.1% (708 of 862 strings)

Translated using Weblate (German)

Currently translated at 99.1% (3468 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 99.1% (3466 of 3497 strings)

Translated using Weblate (Swedish)

Currently translated at 94.6% (53 of 56 strings)

Translated using Weblate (German)

Currently translated at 99.0% (3464 of 3497 strings)

Translated using Weblate (Portuguese)

Currently translated at 93.3% (14 of 15 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese)

Currently translated at 68.5% (591 of 862 strings)

Translated using Weblate (Portuguese)

Currently translated at 91.0% (184 of 202 strings)

Translated using Weblate (Ukrainian)

Currently translated at 53.9% (1885 of 3497 strings)

Translated using Weblate (Ukrainian)

Currently translated at 95.0% (192 of 202 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (928 of 932 strings)

Co-authored-by: Adam Rimanek <adamrimanek.imsp@gmail.com>
Co-authored-by: Alison Alex <spamkari@hotmail.com>
Co-authored-by: Andrés Leiva Barco <andresleibar@gmail.com>
Co-authored-by: Artemis <circlegohard@gmail.com>
Co-authored-by: BMA <simpintis@gmail.com>
Co-authored-by: Begümay Çınar <begumay@proton.me>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Daisy Hsu <HsuD@stmaryscambridge.co.uk>
Co-authored-by: Deleted User <noreply+908@weblate.org>
Co-authored-by: Duggu Ghosh <duggu52d@gmail.com>
Co-authored-by: DumbDump <Schernova13@yandex.ru>
Co-authored-by: Fake Name <alpasalp220@gmail.com>
Co-authored-by: Gizem <gizem100296@gmail.com>
Co-authored-by: Harry Erickson <harry3rickson@gmail.com>
Co-authored-by: Jamie Herbert <pth23tpg@bangor.ac.uk>
Co-authored-by: Jan Freihöfer <jan.stauch.is@gmail.com>
Co-authored-by: Jeremia Hölscher <holscherjury@gmail.com>
Co-authored-by: Jildau Bras <jildaubras@gmail.com>
Co-authored-by: Julius Eikmans <jcs.e@icloud.com>
Co-authored-by: Lawan Fathullah <fathullahlawan@gmail.com>
Co-authored-by: Lizard <li07369427zard@gmail.com>
Co-authored-by: Lucas Vitor de Farias <Lucasvi90robloxbr@gmail.com>
Co-authored-by: Luidson Alejandro Froz Paiva <Luidpah1@gmail.com>
Co-authored-by: Léa <ambredf@outlook.fr>
Co-authored-by: Maria Morant <luisa.morant@yahoo.com>
Co-authored-by: Mausam <mausam_b@protonmail.com>
Co-authored-by: Mia S <miveliina@gmail.com>
Co-authored-by: Nora <yenyisun@gmail.com>
Co-authored-by: Prayaag Thakkar <prayaag13@outlook.com>
Co-authored-by: Rafael Hoyos Arango <rafa131842@gmail.com>
Co-authored-by: SY <smartyeti@proton.me>
Co-authored-by: Sebastian Ramorino <sebarc2005@gmail.com>
Co-authored-by: Sergey <sergejepihin323@gmail.com>
Co-authored-by: Serhii <serzh.photograf@gmail.com>
Co-authored-by: Sonia <sophishport@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Translatlantic Translation <translatlanticom@gmail.com>
Co-authored-by: Uwe B <hbtca@tunixgut.de>
Co-authored-by: Viktor Révész <rviktor@ivankapal.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Yaezch <dkrovel@gmail.com>
Co-authored-by: Yorick Steffens <yorick.steffens@gmail.com>
Co-authored-by: Zarah Lundberg <sar_lun@hotmail.com>
Co-authored-by: donkie <lllllllovinllllll@gmail.com>
Co-authored-by: kim ham <kim@pfts.se>
Co-authored-by: viyu viyu <gnaremoob@binotuz.com>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Co-authored-by: ? <importantdata78@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/es/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/character/de/
Translate-URL: https://translate.habitica.com/projects/habitica/character/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/character/es/
Translate-URL: https://translate.habitica.com/projects/habitica/character/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/character/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/character/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/character/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/character/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/character/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/content/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/content/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/content/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/death/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/death/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/death/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/death/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/death/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/de/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/es/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/front/de/
Translate-URL: https://translate.habitica.com/projects/habitica/front/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/front/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/front/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/front/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/front/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/de/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/es/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/de/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/de/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/overview/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/spells/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/de/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/es/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/uk/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Communityguidelines
Translation: Habitica/Content
Translation: Habitica/Contrib
Translation: Habitica/Death
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Messages
Translation: Habitica/Npc
Translation: Habitica/Overview
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Rebirth
Translation: Habitica/Settings
Translation: Habitica/Spells
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2026-03-05 11:27:29 +01:00
Hafiz 1047b0e03b up habitica-markdown -> 4.1.0 & defensive check 2026-03-02 14:32:58 -06:00
Kalista Payne 159f850bd1 fix(avatar): correct margin override in buy modal 2026-02-27 15:09:11 -06:00
Fiz 42083efb7e Emojis Update (#15620)
* Add group plan selection modal for upgrades

Allow users to select an existing group to upgrade before creating a new one.

* crlf -> lf lint

* set selection of group plan

Also tiny UI fixes

* Update group plan selection to include expired plans

* Add includeExpiredPlans option to group fetching

* force flag when fetching group plans

* Update group plan eligibility check

* Fix eslint error in push notification import

* replace chaining (?.) w/null check

* Remove comment

* set initial selected group plan, and fix card rounding

* format member count

* Show warning for pending party invites when upgrading to paid group plan

Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites

* suppress error toasts for group modal, and UI tweaks for group modal

suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans

* Clear upgradingGroup state after group plan payment

* Update emoji system to native Unicode rendering

* Fix line endings in habiticaMarkdown test

* fix indented code block detection for markdown-it v14

* update habitica-markdown to include v3 emoji dataset  (pointed towards test branch)

* size emoji in markdown

* emoji autocomplete to chat, messages, tasks, and profile

add :emoji shortcode autocomplete dropdown (reusing existing autocomplete mixin w/new helper)

* try upping github-action fix

* trying another github actions fix

* update habitica-markdown package version (v3.0.0 -> v4.0.0)

* Fix emoji autocomplete overlapping actual text

position dropdown below text

* update group-plans info card styles

* Support Melior emoji autocomplete & more places for emoji autocomplete

Include emoji autocomplete in task checklists, tags, challenge name/summary/description

* position emoji autocomplete dropdown below text area

* fix: replace nested ternary

* Emoji autocomplete fixes

Fix emoji autocomplete overlapping checklist text, and add short name emoji autocomplete

* Have emoji autocomplete dropdown directly below text, add to task tag

* Fix emoji autocomplete starting at beginning/end initially

* lint/line length

* Add group plan selection modal for upgrades

Allow users to select an existing group to upgrade before creating a new one.

* crlf -> lf lint

* set selection of group plan

Also tiny UI fixes

* Update group plan selection to include expired plans

* Add includeExpiredPlans option to group fetching

* force flag when fetching group plans

* Update group plan eligibility check

* Fix eslint error in push notification import

* replace chaining (?.) w/null check

* Remove comment

* set initial selected group plan, and fix card rounding

* format member count

* Show warning for pending party invites when upgrading to paid group plan

Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites

* suppress error toasts for group modal, and UI tweaks for group modal

suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans

* Clear upgradingGroup state after group plan payment

* Update emoji system to native Unicode rendering

* Fix line endings in habiticaMarkdown test

* fix indented code block detection for markdown-it v14

* update habitica-markdown to include v3 emoji dataset  (pointed towards test branch)

* size emoji in markdown

* emoji autocomplete to chat, messages, tasks, and profile

add :emoji shortcode autocomplete dropdown (reusing existing autocomplete mixin w/new helper)

* try upping github-action fix

* trying another github actions fix

* update habitica-markdown package version (v3.0.0 -> v4.0.0)

* Fix emoji autocomplete overlapping actual text

position dropdown below text

* update group-plans info card styles

* Support Melior emoji autocomplete & more places for emoji autocomplete

Include emoji autocomplete in task checklists, tags, challenge name/summary/description

* position emoji autocomplete dropdown below text area

* fix: replace nested ternary

* Emoji autocomplete fixes

Fix emoji autocomplete overlapping checklist text, and add short name emoji autocomplete

* Have emoji autocomplete dropdown directly below text, add to task tag

* Fix emoji autocomplete starting at beginning/end initially

* lint/line length

* Revert "trying another github actions fix"

This reverts commit 72fc7fc20e.

* Revert "try upping github-action fix"

This reverts commit 70e48a57aa.

* fix(git): revert ci changes

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-02-26 16:36:37 -06:00
Fiz f21e800b0b Group Plan Modal (#15588)
* Add group plan selection modal for upgrades

Allow users to select an existing group to upgrade before creating a new one.

* crlf -> lf lint

* set selection of group plan

Also tiny UI fixes

* Update group plan selection to include expired plans

* Add includeExpiredPlans option to group fetching

* force flag when fetching group plans

* Update group plan eligibility check

* Fix eslint error in push notification import

* replace chaining (?.) w/null check

* Remove comment

* set initial selected group plan, and fix card rounding

* format member count

* Show warning for pending party invites when upgrading to paid group plan

Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites

* suppress error toasts for group modal, and UI tweaks for group modal

suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans

* Clear upgradingGroup state after group plan payment
2026-02-26 16:02:47 -06:00
Kalista Payne 40122e5621 5.46.0 2026-02-26 12:08:13 -06:00
Kalista Payne 0ae19d9107 Squashed commit of the following:
commit 963b4133ec
Author: Kalista Payne <kalista@habitica.com>
Date:   Thu Feb 19 15:48:41 2026 -0600

    fix(text): clean up some gear descriptions

commit 53999a5b80
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Feb 4 17:18:07 2026 -0600

    fix(content): add seasonal set tokens

commit 4510c90e41
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Feb 4 17:10:24 2026 -0600

    feat(content): March-May 2026
2026-02-24 12:02:52 -06:00
Kalista Payne 68bfebcf30 5.45.0 2026-02-24 10:18:25 -06:00
Phillip Thelen 3e93911e70 Phillip/prod perf2 (#15596)
* Add new api call for kubernetes startup probe

* add hostname as tag for loggly

* Only listen to one change

* increase vite min chunk size

* respond gracefully to shutdown signal

* update server readiness according to mongodb and redis connection

* make larger vite chunks

* fix lint
2026-02-24 10:17:21 -06:00
Kalista Payne 4ea8636f03 fix(profile): correct stat display for class gear 2026-02-20 13:19:56 -06:00
Kalista Payne 9f97a09b8c fix(export): remove deprecated routes 2026-02-19 15:45:38 -06:00
Phillip Thelen eccc115b73 Admin Panel fixes (#15613)
* fix profile link to admin panel

* fix profile looking broken when no background is equipped
2026-02-19 11:11:25 -06:00
Tanmay Nalawade 2b26eb2bd1 Habit and Daily task counts not displaying for certain filters (#15595)
* habits-all fixed

* dailies section fixed

* to do counter fixed

* Remove linting changes and apply logic fix with single quotes

* refactor(tasks): simpler badgeCount logic

* fix(lint): remove unused import

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-02-19 11:09:40 -06:00
Kalista Payne 8e042cabc4 5.44.3 2026-02-10 17:37:48 -06:00
Kalista Payne 8abe167848 chore(subproj): update habitica-images 2026-02-10 17:37:45 -06:00
Kalista Payne 3414f962e2 fix(lint): string style and whitespace 2026-02-10 17:13:02 -06:00
Kalista Payne 1b68e6d4d3 fix(event): show Spring Seasonal Shop on Valentines 2026-02-10 17:07:47 -06:00
Kalista Payne 5dd9711413 Update local dev MongoDB versions (#15334)
* chore(mongodb): update local dev MongoDB versions

* fix(github): update mongodb-github-action

* test(api): attempt to gather more detail on failures

* Revert "test(api): attempt to gather more detail on failures"

This reverts commit 215e768e90.

* WIP mongodb-memory-serve

* fix(mongo): start replica set

* run mongo as gh action service

* remove matrix for mongo

* try npm -> docker instead of services

* try "docker compose"

* disable mongo bootstrap from build

* try gh action again

* try newer action version

* working mongo docker compose 🎉

* fix(lint): leave out unused imports

* update lock

* cleanup previous workflow changes

* remove previous code, dont share mongo data folders on runtype (rs and docker)

* mongo docker for testing; align mongodb directory naming

* remove run-rs, add docker:aio script call, use healthcheck to initiate again

* merge docker-compose.yml, fix client port listening

* fix oudated healthcheck param

* chore(mongodb): update local dev MongoDB versions

* fix(github): update mongodb-github-action

* test(api): attempt to gather more detail on failures

* Revert "test(api): attempt to gather more detail on failures"

This reverts commit 215e768e90.

* WIP mongodb-memory-serve

* run mongo as gh action service

* fix(mongo): start replica set

* remove matrix for mongo

* try npm -> docker instead of services

* try "docker compose"

* disable mongo bootstrap from build

* try gh action again

* try newer action version

* working mongo docker compose 🎉

* fix(lint): leave out unused imports

* update lock

* cleanup previous workflow changes

* remove previous code, dont share mongo data folders on runtype (rs and docker)

* mongo docker for testing; align mongodb directory naming

* remove run-rs, add docker:aio script call, use healthcheck to initiate again

* merge docker-compose.yml, fix client port listening

* fix oudated healthcheck param

* fix(config): remove dup keys

* using npx vite during docker aio run

---------

Co-authored-by: negue <eugen.bolz@gmail.com>
2026-01-30 17:19:35 -06:00
Kalista Payne a542277a41 5.44.2 2026-01-26 11:46:03 -06:00
Phillip Thelen cdf8556fd6 Improve performance in production setup (#15594)
* build cached content files for mobile during gulp build

* load already cached content files during startup

* add option for mongoose to define minPoolSize

* cache client index.html for 10 minutes. Improves initial load times

* add option to auth to use lean version of user doc

* add a way to produce a heapdump from the command line

* fix lint
2026-01-26 11:45:44 -06:00
Kalista Payne 3d93390a7a fix(languages): update template characters 2026-01-23 13:09:14 -06:00
Kalista Payne 59f9cfa0f4 5.44.1 2026-01-23 13:03:30 -06:00
Kalista Payne 80d7804f69 fix(strings): use consistent template characters 2026-01-23 12:53:29 -06:00
Kalista Payne 4e5efe09a3 5.44.0 2026-01-22 11:53:20 -06:00
Fiz d42a597672 Merge pull request #15590 from HabitRPG/kalista/paypal-debug
PayPal debug
2026-01-21 16:11:41 -06:00
Phillip Thelen ea17b2e9c7 Rework how strings are localized (#15589)
* replace lodash template usage with micromustache

* remove function brackets from translations

* add newline

* remove old test

* split core translations from content translations

* fix directory not existing

* fix lint
2026-01-21 14:34:25 -06:00
Phillip Thelen f56708cd88 fix most customizations not being pinnable (#15578)
* fix most customizations not being pinnable

* set correct pinTypes

* fix(pinning): correct purchase types for base hair and mustaches

* automatically unpin purchased customizations

* ability to pin customization items

* Fix pin not showing on buy modal

* Pin on buy modal tweak

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Hafiz <hafizbhamidi@gmail.com>
2026-01-21 13:57:42 -06:00
Kalista Payne 005d14f6e8 5.43.4 2026-01-20 14:36:36 -06:00
Weblate c05a96ce6c Translated using Weblate (Polish)
Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Portuguese)

Currently translated at 23.7% (60 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 23.7% (60 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 23.7% (60 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 90.5% (183 of 202 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.1% (113 of 114 strings)

Translated using Weblate (Portuguese)

Currently translated at 27.6% (70 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 92.9% (106 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.8% (3318 of 3497 strings)

Translated using Weblate (Ukrainian)

Currently translated at 98.6% (143 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.8% (3317 of 3497 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 96.8% (245 of 253 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 94.0% (190 of 202 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Portuguese)

Currently translated at 50.1% (1753 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Indonesian)

Currently translated at 97.1% (238 of 245 strings)

Translated using Weblate (Ukrainian)

Currently translated at 79.3% (150 of 189 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (928 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Co-authored-by: Ashlynn <ashlynn.samuella@gmail.com>
Co-authored-by: Cezary Polakowski <arutha86@gmail.com>
Co-authored-by: Daniel Costa Carvalho <danielcostacarvalho@gmail.com>
Co-authored-by: Illana Beatriz Rocha de Oliveira <dev.illanabeatriz@gmail.com>
Co-authored-by: Lu dG <ludgs@outlook.fr>
Co-authored-by: Lyam Santos Peres <karinesanper@gmail.com>
Co-authored-by: Maria Morant <luisa.morant@yahoo.com>
Co-authored-by: Nina Łapaj <ninapaj@gmail.com>
Co-authored-by: Ri Vargas <goldenhaitang@gmail.com>
Co-authored-by: Sonia <sophishport@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/character/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/front/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/id/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/uk/
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Npc
Translation: Habitica/Tasks
2026-01-20 21:33:14 +01:00
Kalista Payne 8fdbfb9dc6 fix(lint): import order 2026-01-16 17:47:05 -06:00
Kalista Payne 057a642baa fix(logger): path 2026-01-16 17:43:54 -06:00
Kalista Payne 6c522157a7 feat(payments): log some anonymous data to chase a bug 2026-01-16 17:38:16 -06:00
Fiz ba9a1ab2a9 Merge pull request #15583 from HabitRPG/kalista/username-sanitize
Sanitize usernames come from social auth
2026-01-15 12:00:56 -06:00
Kalista Payne 4767461c4f 5.43.3 2026-01-14 14:19:54 -06:00
Weblate 847c97dc8f Translated using Weblate (Portuguese (Brazil))
Currently translated at 94.7% (3314 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (189 of 189 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.7% (3314 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Indonesian)

Currently translated at 93.0% (228 of 245 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Polish)

Currently translated at 98.9% (272 of 275 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Danish)

Currently translated at 88.1% (178 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (58 of 58 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Danish)

Currently translated at 66.5% (183 of 275 strings)

Translated using Weblate (German)

Currently translated at 98.9% (3461 of 3497 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Danish)

Currently translated at 92.5% (175 of 189 strings)

Translated using Weblate (Danish)

Currently translated at 9.0% (23 of 253 strings)

Translated using Weblate (German)

Currently translated at 99.0% (200 of 202 strings)

Translated using Weblate (German)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Danish)

Currently translated at 62.5% (172 of 275 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Japanese)

Currently translated at 96.5% (195 of 202 strings)

Translated using Weblate (Japanese)

Currently translated at 98.4% (249 of 253 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 98.3% (3440 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (French)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.7% (3314 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (202 of 202 strings)

Co-authored-by: Ashlynn <ashlynn.samuella@gmail.com>
Co-authored-by: Douglas Meneghetti <douglasrizzom@gmail.com>
Co-authored-by: Gabriela <gabisouzars5@gmail.com>
Co-authored-by: Helene Bæck Christensen <helene.baeck@gmail.com>
Co-authored-by: Henrique Ferreira <pedroferreira217.ph@gmail.com>
Co-authored-by: Illana Beatriz Rocha de Oliveira <dev.illanabeatriz@gmail.com>
Co-authored-by: Isabela de Carvalho <isabela.c.escritora@gmail.com>
Co-authored-by: Joanna K <joanna.kociolek0@gmail.com>
Co-authored-by: Kenvinn <kevinsavio514@gmail.com>
Co-authored-by: Lucas Rafaldini <lucas.rafaldini@gmail.com>
Co-authored-by: Luã Fhelyp Guimarães <fhelypg@gmail.com>
Co-authored-by: Lyam Santos Peres <kaka1213spaenrteoss@gmail.com>
Co-authored-by: Lyam Santos Peres <karinesanper@gmail.com>
Co-authored-by: Maycon Douglas Silva Santos <maycondss@live.com>
Co-authored-by: Ri Vargas <goldenhaitang@gmail.com>
Co-authored-by: Rodrigo Gonçalves Braga <rgbraga@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Thiago Braga <thibraga06@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: purea <g@agaric.eu>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/de/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/id/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/character/da/
Translate-URL: https://translate.habitica.com/projects/habitica/character/de/
Translate-URL: https://translate.habitica.com/projects/habitica/character/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/character/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/character/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/id/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/death/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/da/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/id/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/front/da/
Translate-URL: https://translate.habitica.com/projects/habitica/front/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/id/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/overview/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/da/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/pt_BR/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Communityguidelines
Translation: Habitica/Death
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Messages
Translation: Habitica/Npc
Translation: Habitica/Overview
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Settings
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2026-01-14 21:18:58 +01:00
Kalista Payne 215b26acac fix(auth): run the actual verify check 2026-01-13 17:01:09 -06:00
Phillip Thelen e223e7821a add config to disable ssl and base_url enforcement (#15587) 2026-01-13 16:44:23 -06:00
Fiz 8134fa7c00 Chat optimization (#15545)
* fix(content): textual tweaks and updates

* fix(link): direct to FAQ instead of wiki

* fix(faq): correct Markdown

* Show orb of rebirth confirmation modal after use (window refresh)

* Set and check rebirth confirmation modal from localstorage

Set and check rebirth confirmation modal from localstorage after window reload

* Don't show orb of rebirth confirmation modal until page reloads

* message effective limit optimization

* Keep max limit for web (400 recent messages)

* Fix amount of messages initially being shown

* PM_PER_PAGE set to 50

* Increases number of messages in inbox test

* Increases number of messages for inbox pagination test

* Set and check rebirth confirmation modal from localstorage

Set and check rebirth confirmation modal from localstorage after window reload

* Don't show orb of rebirth confirmation modal until page reloads

* message effective limit optimization

* Keep max limit for web (400 recent messages)

* Add UUID validation for 'before' query parameter

* add party message stress test tool in admin panel

* lint

* add MAX_PM_COUNT of 400, admin tool for stress testing messages

* comment

* update stress test inbox message tool to use logged in user

* comment

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-01-13 14:43:09 -06:00
Kalista Payne 5c555cbf88 fix(auth): revert attempted perf tweak 2026-01-07 17:52:33 -06:00
Kalista Payne 7379c7b230 fix(auth): handle potential collisions on username 2026-01-07 17:26:42 -06:00
Kalista Payne c055537c38 fix(lint): whitespace 2026-01-07 16:49:22 -06:00
Kalista Payne 7559feec8e Revert "fix(lint): whitespace; also revert username fix for further QA"
This reverts commit dcd15a58ebf41d9e44c89c675419ecb7d36b946d.
2026-01-07 16:49:22 -06:00
Kalista Payne 43808696a8 fix(lint): whitespace; also revert username fix for further QA 2026-01-07 16:49:22 -06:00
Kalista Payne 72fb41c7e0 fix(lint): missing variable declaration 2026-01-07 16:45:19 -06:00
Kalista Payne 3bf18e09ed fix(auth): strip invalid characters during social reg 2026-01-07 16:45:19 -06:00
462 changed files with 14065 additions and 6328 deletions
+18 -8
View File
@@ -82,7 +82,7 @@ jobs:
CI: true
NODE_ENV: test
- run: npm run test:sanity
common:
runs-on: ubuntu-latest
strategy:
@@ -129,13 +129,13 @@ jobs:
CI: true
NODE_ENV: test
- run: npm run test:content
api-unit:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [4.2]
mongodb-version: [7.0]
steps:
- uses: actions/checkout@v4
with:
@@ -144,11 +144,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.3.0
uses: supercharge/mongodb-github-action@1.11.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -158,15 +160,17 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api:unit
env:
REQUIRES_SERVER=true: true
api-v3-integration:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [4.2]
mongodb-version: [7.0]
steps:
- uses: actions/checkout@v4
with:
@@ -176,10 +180,11 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.3.0
uses: supercharge/mongodb-github-action@1.11.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -189,15 +194,18 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api-v3:integration
env:
REQUIRES_SERVER=true: true
api-v4-integration:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [4.2]
mongodb-version: [7.0]
steps:
- uses: actions/checkout@v4
with:
@@ -207,10 +215,11 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.3.0
uses: supercharge/mongodb-github-action@1.11.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -220,6 +229,7 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api-v4:integration
env:
REQUIRES_SERVER=true: true
+1 -1
View File
@@ -47,5 +47,5 @@ webpack.webstorm.config
# mongodb replica set for local dev
mongodb-*.tgz
/mongodb-data*
/mongodb-*
/.nyc_output
+2 -3
View File
@@ -46,7 +46,7 @@
"MAINTENANCE_MODE": "false",
"MONGODB_POOL_SIZE": "10",
"MONGODB_SOCKET_TIMEOUT": "20000",
"NODE_DB_URI": "mongodb://localhost:27017/habitica-dev?replicaSet=rs",
"NODE_DB_URI": "mongodb://localhost:27017/habitica-dev?replicaSet=rs&directConnection=true&readPreference=secondary",
"NODE_ENV": "development",
"PATH": "bin:node_modules/.bin:/usr/local/bin:/usr/bin:/bin",
"PAYPAL_BILLING_PLANS_basic_12mo": "basic_12mo",
@@ -75,7 +75,6 @@
"S3_ACCESS_KEY_ID": "accessKeyId",
"S3_BUCKET": "bucket",
"S3_SECRET_ACCESS_KEY": "secretAccessKey",
"SESSION_SECRET_IV": "12345678912345678912345678912345",
"SESSION_SECRET_KEY": "1234567891234567891234567891234567891234567891234567891234567891",
"SESSION_SECRET": "YOUR SECRET HERE",
"SITE_HTTP_AUTH_ENABLED": "false",
@@ -90,7 +89,7 @@
"STRIPE_API_KEY": "aaaabbbbccccddddeeeeffff00001111",
"STRIPE_PUB_KEY": "22223333444455556666777788889999",
"STRIPE_WEBHOOKS_ENDPOINT_SECRET": "111111",
"TEST_DB_URI": "mongodb://localhost:27017/habitica-test?replicaSet=rs",
"TEST_DB_URI": "mongodb://localhost:27017/habitica-test?replicaSet=rs&directConnection=true&readPreference=secondary",
"TIME_TRAVEL_ENABLED": "false",
"TRUSTED_DOMAINS": "localhost,https://habitica.com",
"WEB_CONCURRENCY": 1
-53
View File
@@ -1,53 +0,0 @@
services:
client:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "run", "client:dev"]
depends_on:
- server
environment:
- BASE_URL=http://server:3000
networks:
- habitica
ports:
- "8080:8080"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
- /usr/src/habitica/website/client/node_modules
server:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
depends_on:
mongo:
condition: service_healthy
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
networks:
- habitica
ports:
- "3000:3000"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
mongo:
image: mongo:5.0.23
restart: unless-stopped
command: ["--replSet", "rs", "--bind_ip_all", "--port", "27017"]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
networks:
- habitica
ports:
- "27017:27017"
networks:
habitica:
driver: bridge
+23
View File
@@ -0,0 +1,23 @@
networks:
mongodb-network:
name: "mongodb-network"
driver: bridge
services:
mongodb:
image: "mongo:7.0"
container_name: "habitica-mongodb-only"
networks:
- mongodb-network
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
+23
View File
@@ -0,0 +1,23 @@
networks:
mongodb-network:
name: "mongodb-network"
driver: bridge
services:
mongodb:
image: "mongo:7.0"
container_name: "habitica-mongodb-test"
networks:
- mongodb-network
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker-testing:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
+43 -22
View File
@@ -1,35 +1,56 @@
version: "3"
services:
client:
build: .
networks:
- habitica
environment:
- BASE_URL=http://server:3000
ports:
- "8080:8080"
command: ["npm", "run", "client:dev"]
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "run", "client:dev:docker"]
depends_on:
- server
server:
build: .
ports:
- "3000:3000"
environment:
- BASE_URL=http://server:3000
networks:
- habitica
ports:
- "5173:5173"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
- /usr/src/habitica/website/client/node_modules
server:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
depends_on:
mongo:
condition: service_healthy
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
depends_on:
- mongo
mongo:
image: mongo:3.6
ports:
- "27017:27017"
networks:
- habitica
ports:
- "3000:3000"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
mongo:
image: "mongo:7.0"
container_name: "habitica-mongodb"
networks:
- habitica
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
networks:
habitica:
+12 -9
View File
@@ -5,7 +5,7 @@ import path from 'path';
import babel from 'gulp-babel';
import os from 'os';
import fs from 'fs';
import spawn from 'cross-spawn'; // eslint-disable-line import/no-extraneous-dependencies
import spawn from 'cross-spawn';
import clean from 'rimraf';
gulp.task('build:babel:server', () => gulp.src('website/server/**/*.js')
@@ -35,7 +35,7 @@ gulp.task('build:prod', gulp.series(
// When used on windows `run-rs` must first be run without the `--keep` option
// in order to be setup correctly, afterwards it can be used.
const MONGO_PATH = path.join(__dirname, '/../mongodb-data/');
const MONGO_PATH = path.join(__dirname, '/../mongodb-data-docker/');
gulp.task('build:prepare-mongo', async () => {
if (fs.existsSync(MONGO_PATH)) {
@@ -51,29 +51,32 @@ gulp.task('build:prepare-mongo', async () => {
console.log('MongoDB data folder is missing, setting up.'); // eslint-disable-line no-console
// use run-rs without --keep, kill it as soon as the replica set starts
const runRsProcess = spawn('run-rs', ['-v', '4.1.1', '-l', 'ubuntu1804', '--dbpath', 'mongodb-data', '--number', '1', '--quiet']);
const dockerMongoProcess = spawn('npm', ['run', 'docker:mongo:dev']);
for await (const chunk of runRsProcess.stdout) {
let manuallyStopped = false;
for await (const chunk of dockerMongoProcess.stdout) {
const stringChunk = chunk.toString();
console.log(stringChunk); // eslint-disable-line no-console
// kills the process after the replica set is setup
if (stringChunk.includes('Started replica set')) {
if (stringChunk.includes('mongod startup complete')) {
console.log('MongoDB setup correctly.'); // eslint-disable-line no-console
runRsProcess.kill();
dockerMongoProcess.kill();
manuallyStopped = true;
}
}
let error = '';
for await (const chunk of runRsProcess.stderr) {
for await (const chunk of dockerMongoProcess.stderr) {
const stringChunk = chunk.toString();
error += stringChunk;
}
const exitCode = await new Promise(resolve => {
runRsProcess.on('close', resolve);
dockerMongoProcess.on('close', resolve);
});
if (exitCode || error.length > 0) {
if (!manuallyStopped && (exitCode || error.length > 0)) {
// remove any leftover files
clean.sync(MONGO_PATH);
+61 -26
View File
@@ -6,9 +6,21 @@ gulp.task('cache:content', done => {
// Requiring at runtime because these files access `common`
// code which in production works only if transpiled so after
// gulp build:babel:common has run
const { CONTENT_CACHE_PATH, getLocalizedContentResponse } = require('../website/server/libs/content'); // eslint-disable-line global-require
const {
CONTENT_CACHE_PATH,
getLocalizedContentResponse,
IOS_FILTER,
ANDROID_FILTER,
buildFilterObject,
hashForFilter,
} = require('../website/server/libs/content'); // eslint-disable-line global-require
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const iosHash = hashForFilter(IOS_FILTER);
const iosFilterObj = buildFilterObject(IOS_FILTER);
const androidHash = hashForFilter(ANDROID_FILTER);
const androidFilterObj = buildFilterObject(ANDROID_FILTER);
try {
// create the cache folder (if it doesn't exist)
try {
@@ -26,33 +38,56 @@ gulp.task('cache:content', done => {
getLocalizedContentResponse(langCode),
'utf8',
);
});
done();
} catch (err) {
done(err);
}
});
gulp.task('cache:i18n', done => {
// Requiring at runtime because these files access `common`
// code which in production works only if transpiled so after
// gulp build:babel:common has run
const { BROWSER_SCRIPT_CACHE_PATH, geti18nBrowserScript } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
try {
// create the cache folder (if it doesn't exist)
try {
fs.mkdirSync(BROWSER_SCRIPT_CACHE_PATH);
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
// create and save the i18n browser script for each language
langCodes.forEach(languageCode => {
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}${languageCode}.js`,
geti18nBrowserScript(languageCode),
`${CONTENT_CACHE_PATH}${langCode}${iosHash}.json`,
getLocalizedContentResponse(langCode, iosFilterObj),
'utf8',
);
fs.writeFileSync(
`${CONTENT_CACHE_PATH}${langCode}${androidHash}.json`,
getLocalizedContentResponse(langCode, androidFilterObj),
'utf8',
);
});
done();
} catch (err) {
done(err);
}
});
function safeMkdir (path) {
try {
fs.mkdirSync(path);
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
gulp.task('cache:i18n', done => {
// Requiring at runtime because these files access `common`
// code which in production works only if transpiled so after
// gulp build:babel:common has run
const { BROWSER_SCRIPT_CACHE_PATH, geti18nCoreBrowserScript, geti18nContentBrowserScript } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
try {
// create the cache folders (if they doesn't exist)
safeMkdir(BROWSER_SCRIPT_CACHE_PATH);
safeMkdir(`${BROWSER_SCRIPT_CACHE_PATH}core/`);
safeMkdir(`${BROWSER_SCRIPT_CACHE_PATH}content/`);
// create and save the i18n browser script for each language
langCodes.forEach(languageCode => {
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}core/${languageCode}.js`,
geti18nCoreBrowserScript(languageCode),
'utf8',
);
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}content/${languageCode}.js`,
geti18nContentBrowserScript(languageCode),
'utf8',
);
});
+5
View File
@@ -53,6 +53,11 @@ gulp.task('test:prepare:mongo', cb => {
const mongooseOptions = getDefaultConnectionOptions();
const connectionUrl = getDevelopmentConnectionUrl(TEST_DB_URI);
console.info({
mongooseOptions,
connectionUrl,
});
mongoose.connect(connectionUrl, mongooseOptions)
.then(() => mongoose.connection.dropDatabase())
.then(() => mongoose.connection.close()).then(() => {
+827 -704
View File
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.43.2",
"version": "5.46.4",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@@ -19,6 +19,7 @@
"bcrypt": "^5.1.1",
"body-parser": "^1.20.3",
"bootstrap": "^4.6.2",
"bullmq": "^5.71.1",
"compression": "^1.8.1",
"cookie-session": "^2.1.1",
"coupon-code": "^0.4.5",
@@ -39,19 +40,23 @@
"gulp-filter": "^7.0.0",
"gulp-imagemin": "^7.1.0",
"gulp.spritesmith": "^6.13.0",
"habitica-markdown": "^3.0.0",
"habitica-markdown": "^4.1.0",
"heapdump": "^0.3.15",
"helmet": "^4.6.0",
"in-app-purchase": "^1.11.3",
"ioredis": "^5.10.1",
"js2xmlparser": "^5.0.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^2.1.5",
"lodash": "^4.17.21",
"merge-stream": "^2.0.0",
"method-override": "^3.0.0",
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"nconf": "^0.12.1",
"node-gcm": "^1.0.5",
"on-headers": "^1.1.0",
@@ -63,7 +68,6 @@
"pp-ipn": "^1.1.0",
"ps-tree": "^1.0.0",
"rate-limiter-flexible": "^2.4.2",
"redis": "^3.1.2",
"remove-markdown": "^0.5.0",
"rimraf": "^3.0.2",
"short-uuid": "^4.2.2",
@@ -100,13 +104,16 @@
"coverage": "nyc report --reporter=html --report-dir coverage/results; open coverage/results/index.html",
"sprites": "gulp sprites:compile",
"client:dev": "cd website/client && npm run serve",
"client:dev:docker": "cd website/client && npm run serve:docker",
"client:build": "cd website/client && npm run build",
"client:unit": "cd website/client && npm run test:unit",
"start": "node --watch ./website/server/index.js",
"start:simple": "node ./website/server/index.js",
"debug": "node --watch --inspect ./website/server/index.js",
"mongo:dev": "run-rs -v 7.0.23 -l ubuntu2214 --keep --dbpath mongodb-data --number 1 --quiet",
"mongo:test": "run-rs -v 7.0.23 -l ubuntu2214 --keep --dbpath mongodb-data-testing --number 1 --quiet",
"docker:aio": "docker compose -f docker-compose.yml up",
"docker:mongo:dev": "docker compose -f docker-compose.mongo-only.yml up",
"docker:mongo:dev:down": "docker compose -f docker-compose.mongo-only.yml down",
"docker:mongo:test": "docker compose -f docker-compose.mongo-test-local.yml up",
"mongo:test": "node scripts/start-local-mongo.mjs --test-db",
"postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install",
"apidoc": "gulp apidoc",
"heroku-postbuild": ".heroku/report_deploy.sh"
@@ -122,7 +129,6 @@
"monk": "^7.3.4",
"nyc": "^15.1.0",
"require-again": "^2.0.0",
"run-rs": "^0.7.7",
"sinon-chai": "^3.7.0",
"sinon-stub-promise": "^4.0.0"
}
+22 -25
View File
@@ -1,9 +1,9 @@
/* eslint-disable global-require */
import got from 'got';
import nconf from 'nconf';
import requireAgain from 'require-again';
import { TAVERN_ID } from '../../../../website/server/models/group';
import { defer } from '../../../helpers/api-unit.helper';
import worker from '../../../../website/server/libs/worker';
function getUser () {
return {
@@ -127,7 +127,7 @@ describe('emails', () => {
let sendTxn = null;
beforeEach(() => {
sandbox.stub(got, 'post').returns(defer().promise);
sandbox.stub(worker, 'sendJob').returns(defer().promise);
const nconfGetStub = sandbox.stub(nconf, 'get');
nconfGetStub.withArgs('IS_PROD').returns(true);
@@ -149,13 +149,12 @@ describe('emails', () => {
};
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(value => Array.isArray(value) && value[0].name === mailingInfo.name, 'matches mailing info array'),
},
expect(worker.sendJob).to.be.called;
expect(worker.sendJob).to.be.calledWith('email', sinon.match({
identifier: emailType,
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(value => Array.isArray(value) && value[0].name === mailingInfo.name, 'matches mailing info array'),
},
}));
});
@@ -168,7 +167,7 @@ describe('emails', () => {
};
sendTxn(mailingInfo, emailType);
expect(got.post).not.to.be.called;
expect(worker.sendJob).not.to.be.called;
});
it('throws error when mail target is only a string', async () => {
@@ -233,13 +232,12 @@ describe('emails', () => {
const mailingInfo = getUser();
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(val => val[0]._id === mailingInfo._id),
},
expect(worker.sendJob).to.be.called;
expect(worker.sendJob).to.be.calledWith('email', sinon.match({
identifier: emailType,
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(val => val[0]._id === mailingInfo._id),
},
}));
});
@@ -253,15 +251,14 @@ describe('emails', () => {
const variables = [];
sendTxn(mailingInfo, emailType, variables);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),
personalVariables: sinon.match(value => value[0].rcpt === mailingInfo.email
&& value[0].vars[0].name === 'RECIPIENT_NAME'
expect(worker.sendJob).to.be.called;
expect(worker.sendJob).to.be.calledWith('email', sinon.match({
identifier: emailType,
data: {
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),
personalVariables: sinon.match(value => value[0].rcpt === mailingInfo.email
&& value[0].vars[0].name === 'RECIPIENT_NAME'
&& value[0].vars[1].name === 'RECIPIENT_UNSUB_URL', 'matches personal variables'),
},
},
}));
});
@@ -66,13 +66,15 @@ describe('Amazon Payments - Cancel Subscription', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
subscriptionBlock = common.content.subscriptionBlocks[subKey];
subscriptionLength = subscriptionBlock.months * 30;
@@ -30,12 +30,14 @@ describe('Amazon Payments - Subscribe', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
amount = common.content.subscriptionBlocks[subKey].price;
billingAgreementId = 'billingAgreementId';
@@ -246,11 +248,6 @@ describe('Amazon Payments - Subscribe', () => {
user.guilds.push(groupId);
await user.save();
// Add existing users
user = new User();
user.guilds.push(groupId);
await user.save();
// Set expected amount
sub.key = 'group_monthly';
sub.price = 9;
@@ -128,11 +128,12 @@ describe('Purchasing a group plan for group', () => {
expect(publicGroup.purchased.plan.planId).to.not.exist;
data.groupId = publicGroup._id;
// Public Guilds are no longer even findable
await expect(api.createSubscription(data))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('onlyPrivateGuildsCanUpgrade'),
httpCode: 404,
name: 'NotFound',
message: i18n.t('groupNotFound'),
});
const updatedGroup = await Group.findById(publicGroup._id).exec();
@@ -30,13 +30,15 @@ describe('paypal - subscribeCancel', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = groupCustomerId;
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
nextBillingDate = new Date();
@@ -236,7 +236,7 @@ describe('Stripe - Checkout', () => {
const group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
const groupId = group._id;
@@ -376,11 +376,13 @@ describe('Stripe - Checkout', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
groupId = group._id;
await group.save();
user.guilds.push(group._id);
await user.save();
});
it('throws if user is not allowed to change group plan', async () => {
@@ -136,7 +136,7 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
groupId = group._id;
@@ -315,12 +315,14 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
groupId = group._id;
});
@@ -50,5 +50,59 @@ describe('UserNotification Model', () => {
expect(safeNotifications[0].type).to.equal('NEW_CHAT_MESSAGE');
expect(safeNotifications[0].id).to.equal('123');
});
it('removes duplicate STREAK_ACHIEVEMENT notifications', () => {
// Fixes issue #13325 - Users receiving duplicate streak achievement notifications
const notifications = [
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 123,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 456,
data: {},
}),
new UserNotification({
type: 'CRON',
id: 789,
data: {},
}), // different type, should be kept
];
const safeNotifications = UserNotification.cleanupCorruptData(notifications);
expect(safeNotifications.length).to.equal(2);
expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT');
expect(safeNotifications[0].id).to.equal('123');
expect(safeNotifications[1].type).to.equal('CRON');
expect(safeNotifications[1].id).to.equal('789');
});
it('handles multiple STREAK_ACHIEVEMENT duplicates correctly', () => {
// Test case: 3 duplicate STREAK_ACHIEVEMENT notifications
const notifications = [
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 111,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 222,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 333,
data: {},
}),
];
const safeNotifications = UserNotification.cleanupCorruptData(notifications);
expect(safeNotifications.length).to.equal(1);
expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT');
expect(safeNotifications[0].id).to.equal('111'); // Keep first one
});
});
});
@@ -5,6 +5,8 @@ import {
createAndPopulateGroup,
translate as t,
} from '../../../../helpers/api-integration/v3';
import { model as Group } from '../../../../../website/server/models/group';
import { TAVERN_ID } from '../../../../../website/common/script/constants';
describe('POST /challenges/:challengeId/join', () => {
it('returns error when challengeId is not a valid UUID', async () => {
@@ -27,6 +29,37 @@ describe('POST /challenges/:challengeId/join', () => {
});
});
context('public Guild', () => {
let group;
let groupLeader;
let members;
let challenge;
before(async () => {
({ group, groupLeader, members } = await createAndPopulateGroup({
groupDetails: {
name: 'test group',
type: 'guild',
privacy: 'private',
},
members: 1,
upgradeToGroupPlan: true,
}));
challenge = await generateChallenge(groupLeader, group);
// Creation API is shut down, we need to simulate an extant public group
await Group.updateOne({ _id: group._id }, { $set: { privacy: 'public' }, $unset: { 'purchased.plan': 1 } }).exec();
});
it('returns error when challengeId is in an old public Guild', async () => {
const authorizedUser = members[0]; // eslint-disable-line prefer-destructuring
await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeNotFound'),
});
});
});
context('Joining a valid challenge', () => {
let groupLeader;
let group;
@@ -66,6 +99,15 @@ describe('POST /challenges/:challengeId/join', () => {
expect(res.name).to.equal(challenge.name);
});
it('succeeds when it\'s a Tavern challenge, even if the user isn\'t a "member" of Tavern', async () => {
const tavern = await groupLeader.get(`/groups/${TAVERN_ID}`);
const tavernChallenge = await generateChallenge(groupLeader, tavern, { prize: 1 });
const generalUser = await generateUser();
const res = await generalUser.post(`/challenges/${tavernChallenge._id}/join`);
expect(res.name).to.equal(tavernChallenge.name);
});
it('returns challenge data', async () => {
const res = await authorizedUser.post(`/challenges/${challenge._id}/join`);
@@ -62,9 +62,9 @@ describe('GET /groups/:groupId/chat', () => {
it('returns error if user attempts to fetch a sunset Guild', async () => {
await expect(user.get(`/groups/${group._id}/chat`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
});
@@ -121,9 +121,9 @@ describe('POST /chat/:chatId/like', () => {
await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
});
@@ -1,35 +0,0 @@
import { v4 as generateUUID } from 'uuid';
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
xdescribe('GET /export/avatar-:memberId.html', () => {
let user;
before(async () => {
user = await generateUser();
});
it('validates req.params.memberId', async () => {
await expect(user.get('/export/avatar-:memberId.html')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('handles non-existing members', async () => {
const dummyId = generateUUID();
await expect(user.get(`/export/avatar-${dummyId}.html`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('userWithIDNotFound', { userId: dummyId }),
});
});
it('returns an html page', async () => {
const res = await user.get(`/export/avatar-${user._id}.html`);
expect(res.substring(0, 100).indexOf('<!DOCTYPE html>')).to.equal(0);
});
});
@@ -1,3 +0,0 @@
// TODO how to test this route since it points to a file on AWS s3?
describe('GET /export/avatar-:memberId.png', () => {});
@@ -38,7 +38,7 @@ describe('GET /export/inbox.html', () => {
it('renders the markdown messages as html', async () => {
const res = await user.get('/export/inbox.html');
expect(res).to.include('img class="habitica-emoji"');
expect(res).to.include('😄');
expect(res).to.include('<h1>Hello!</h1>');
expect(res).to.include('<li>list 1</li>');
});
@@ -46,7 +46,7 @@ describe('GET /export/inbox.html', () => {
it('sorts messages from newest to oldest', async () => {
const res = await user.get('/export/inbox.html');
const emojiPosition = res.indexOf('img class="habitica-emoji"');
const emojiPosition = res.indexOf('😄');
const headingPosition = res.indexOf('<h1>Hello!</h1>');
const listPosition = res.indexOf('<li>list 1</li>');
@@ -47,7 +47,7 @@ describe('GET /inbox/messages', () => {
it('returns four messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 10; i += 1) {
for (let i = 0; i < 50; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',
@@ -193,23 +193,6 @@ describe('POST /groups/:groupId/quests/force-start', () => {
expect(questingGroup.quest.members[notInPartyUser._id]).to.not.exist;
});
it('removes users who have been deleted from quest.members', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
await partyMembers[0].del('/user', {
password: 'password',
});
await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
await sleep(0.5);
await questingGroup.sync();
expect(questingGroup.quest.members[partyMembers[0]._id]).to.not.exist;
});
it('removes users who don\'t have true value in quest.members from quest.members', async () => {
const partyMemberThatRejects = partyMembers[1];
const partyMemberThatIgnores = partyMembers[2];
@@ -1,13 +1,7 @@
import {
each,
map,
} from 'lodash';
import {
checkExistence,
createAndPopulateGroup,
generateGroup,
generateUser,
generateChallenge,
translate as t,
} from '../../../../helpers/api-integration/v3';
import {
@@ -15,6 +9,7 @@ import {
sha1Encrypt as sha1EncryptPassword,
} from '../../../../../website/server/libs/password';
import * as email from '../../../../../website/server/libs/email';
import sendJob from '../../../../../website/server/libs/worker';
const DELETE_CONFIRMATION = 'DELETE';
@@ -47,12 +42,13 @@ describe('DELETE /user', () => {
});
});
it('deletes the user', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
it('sends deletion job to worker', async () => {
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.del('/user', {
password,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
it('returns an error if excessive feedback is supplied', async () => {
@@ -84,53 +80,6 @@ describe('DELETE /user', () => {
});
});
it('deletes the user\'s tasks', async () => {
await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
await user.sync();
// gets the user's tasks ids
const ids = [];
each(user.tasksOrder, idsForOrder => {
ids.push(...idsForOrder);
});
expect(ids.length).to.be.above(0); // make sure the user has some task to delete
await user.del('/user', {
password,
});
await Promise.all(map(ids, id => expect(checkExistence('tasks', id)).to.eventually.eql(false)));
});
it('reduces memberCount in challenges user is linked to', async () => {
const populatedGroup = await createAndPopulateGroup({
members: 2,
});
const { group } = populatedGroup;
const authorizedUser = populatedGroup.members[1];
const challenge = await generateChallenge(populatedGroup.groupLeader, group);
await populatedGroup.groupLeader.post(`/challenges/${challenge._id}/join`);
await authorizedUser.post(`/challenges/${challenge._id}/join`);
await challenge.sync();
expect(challenge.memberCount).to.eql(2);
await authorizedUser.del('/user', {
password,
});
await challenge.sync();
expect(challenge.memberCount).to.eql(1);
});
it('sends feedback to the admin email', async () => {
sandbox.spy(email, 'sendTxn');
@@ -158,10 +107,10 @@ describe('DELETE /user', () => {
});
it('deletes the user with a legacy sha1 password', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
const textPassword = 'mySecretPassword';
const salt = sha1MakeSalt();
const sha1HashedPassword = sha1EncryptPassword(textPassword, salt);
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.updateOne({
'auth.local.hashed_password': sha1HashedPassword,
@@ -179,7 +128,8 @@ describe('DELETE /user', () => {
await user.del('/user', {
password: textPassword,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
context('last member of a party', () => {
@@ -213,11 +163,12 @@ describe('DELETE /user', () => {
});
it('deletes a Google user', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.del('/user', {
password: DELETE_CONFIRMATION,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
});
@@ -232,12 +183,13 @@ describe('DELETE /user', () => {
});
});
it('deletes a Apple user', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
it('deletes an Apple user', async () => {
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.del('/user', {
password: DELETE_CONFIRMATION,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
});
});
@@ -65,6 +65,52 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
});
it('includes sanitized version of provided username', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Google User Name',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.equal('GoogleUserName');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.equal('googleusername');
});
it('generates a random username if provided username contains only disallowed characters', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Áîüè',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('generates a random username if provided username contains a disallowed word', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'i am a TESTPLACEHOLDERSLURWORDHERE',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('generates a random username if sanitized username conflicts with an extant user', async () => {
user = await generateUser({ 'auth.local.username': 'GoogleUserName' });
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Google User Name',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
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
@@ -66,7 +66,7 @@ describe('GET /inbox/conversations', () => {
it('returns five messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 10; i += 1) {
for (let i = 0; i < 50; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',
+67
View File
@@ -0,0 +1,67 @@
import md from 'habitica-markdown';
describe('habiticaMarkdown emoji plugin', () => {
it('renders standard emoji as Unicode', () => {
const result = md.render(':smile:');
expect(result).to.include('😄');
expect(result).not.to.include('img');
});
it('renders thumbsup emoji as Unicode', () => {
const result = md.render(':thumbsup:');
expect(result).to.include('👍');
});
it('renders +1 emoji as Unicode', () => {
const result = md.render(':+1:');
expect(result).to.include('👍');
});
it('renders melior as an img tag', () => {
const result = md.render(':melior:');
expect(result).to.include('<img class="habitica-emoji"');
expect(result).to.include('src="https://s3.amazonaws.com/habitica-assets/cdn/emoji/melior.png"');
expect(result).to.include('alt="melior"');
});
it('does NOT convert emoji inside markdown links', () => {
const result = md.render('[:smile: link](http://example.com)');
expect(result).to.include(':smile: link');
expect(result).not.to.include('😄');
});
it('converts emoji outside of links normally', () => {
const result = md.render(':smile: [link](http://example.com)');
expect(result).to.include('😄');
expect(result).to.include('link');
});
it('leaves removed custom emoji (bowtie) as literal text', () => {
const result = md.render(':bowtie:');
expect(result).to.include(':bowtie:');
expect(result).not.to.include('img');
});
it('leaves unknown shortcodes as literal text', () => {
const result = md.render(':nonexistent_emoji_xyz:');
expect(result).to.include(':nonexistent_emoji_xyz:');
});
it('renders new emoji not in the old dataset', () => {
const result = md.render(':yawning_face:');
expect(result).to.include('🥱');
});
it('supports unsafeHTMLRender', () => {
const result = md.unsafeHTMLRender('<b>bold</b> :smile:');
expect(result).to.include('<b>bold</b>');
expect(result).to.include('😄');
});
it('supports renderWithMentions', () => {
const result = md.renderWithMentions(':smile: @testuser', { userName: 'testuser' });
expect(result).to.include('😄');
expect(result).to.include('at-text');
expect(result).to.include('at-highlight');
});
});
+17 -8
View File
@@ -211,22 +211,32 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(2);
});
it('does not increment rebirth achievements when level is lower than previous', async () => {
it('increments rebirth achievements even when level is lower than previous', async () => {
user.stats.lvl = 2;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(1);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirthLevel).to.equal(3);
});
it('always increments rebirth achievements when level is MAX_LEVEL', async () => {
it('updates rebirthLevel when current level is higher than previous', async () => {
user.stats.lvl = 5;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirthLevel).to.equal(5);
});
it('increments rebirth achievements when level is MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL;
user.achievements.rebirths = 1;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 1;
user.achievements.rebirthLevel = MAX_LEVEL;
await rebirth(user);
@@ -234,11 +244,10 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(MAX_LEVEL);
});
it('always increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
it('increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL + 1;
user.achievements.rebirths = 1;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 2;
user.achievements.rebirthLevel = MAX_LEVEL;
await rebirth(user);
+9 -1
View File
@@ -20,6 +20,9 @@ describe('shared.ops.unlock', () => {
beforeEach(() => {
user = generateUser();
user.balance = usersStartingGems;
user.pinnedItems.push({ type: 'background', path: 'backgrounds.backgrounds042016.giant_florals' });
user.pinnedItems.push({ type: 'haircolor', path: 'hair.color.rainbow' });
user.pinnedItems.push({ type: 'shirt', path: 'shirt.convict' });
clock = sandbox.useFakeTimers(new Date('2024-04-10'));
});
@@ -272,6 +275,7 @@ describe('shared.ops.unlock', () => {
});
it('unlocks an item (appearance)', async () => {
expect(user.pinnedItems.findIndex(item => item.type === 'shirt')).to.not.equal(-1);
const path = unlockPath.split(',')[0];
const initialShirts = Object.keys(user.purchased.shirt).length;
const [, message] = await unlock(user, { query: { path } });
@@ -282,11 +286,12 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
expect(user.pinnedItems.findIndex(item => item.type === 'shirt')).to.equal(-1);
});
it('unlocks an item (hair color)', async () => {
user.purchased.hair.color = {};
expect(user.pinnedItems.findIndex(item => item.type === 'haircolor')).to.not.equal(-1);
const path = hairUnlockPath.split(',')[0];
const initialColorHair = Object.keys(user.purchased.hair.color).length;
const [, message] = await unlock(user, { query: { path } });
@@ -297,6 +302,7 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
expect(user.pinnedItems.findIndex(item => item.type === 'haircolor')).to.equal(-1);
});
it('unlocks an item (facial hair)', async () => {
@@ -334,6 +340,7 @@ describe('shared.ops.unlock', () => {
it('unlocks an item (background)', async () => {
const initialBackgrounds = Object.keys(user.purchased.background).length;
expect(user.pinnedItems.findIndex(item => item.type === 'background')).to.not.equal(-1);
const [, message] = await unlock(user, {
query: { path: backgroundUnlockPath },
});
@@ -344,6 +351,7 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, backgroundUnlockPath)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 1.75);
expect(user.pinnedItems.findIndex(item => item.type === 'background')).to.equal(-1);
});
it('handles an invalid hair path gracefully', async () => {
+41
View File
@@ -0,0 +1,41 @@
import { getMatchingSwap, makeSubstitutionMap } from '../../website/common/script/content/constants/aprilFools';
describe('April Fools', () => {
describe('getMatchingSwap', () => {
it('returns Veggie for 2020', () => {
const swap = getMatchingSwap(new Date('2020-04-01'));
expect(swap).to.equal('Veggie');
});
it('returns Alien for 2026', () => {
const swap = getMatchingSwap(new Date('2026-04-01'));
expect(swap).to.equal('Alien');
});
it('Cycles through swaps correctly', () => {
const swap = getMatchingSwap(new Date('2027-04-01'));
expect(swap).to.equal('Veggie');
});
});
describe('makeSubstitutionMap', () => {
it('returns correct substitution for Veggie', () => {
const substitutions = makeSubstitutionMap('Veggie');
expect(substitutions.pets['Pet-Wolf-']).to.equal('Pet-Wolf-Veggie');
expect(substitutions.pets['Pet-TigerCub-']).to.equal('Pet-TigerCub-Veggie');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-BearCub-Veggie');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Veggie');
expect(substitutions.pets.noPet).to.equal('Pet-Wolf-Veggie');
expect(substitutions.pets.noPetIOS).to.equal('Pet-TigerCub-Veggie');
expect(substitutions.pets.noPetAndroid).to.equal('Pet-Cactus-Veggie');
});
it('returns correct substitution for Cryptid', () => {
const substitutions = makeSubstitutionMap('Cryptid');
expect(substitutions.pets['Pet-Fox-']).to.equal('Pet-Fox-Cryptid');
expect(substitutions.pets['Pet-FlyingPig-']).to.equal('Pet-FlyingPig-Cryptid');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-BearCub-Cryptid');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Cryptid');
expect(substitutions.pets.noPet).to.equal('Pet-Wolf-Cryptid');
expect(substitutions.pets.noPetAndroid).to.equal('Pet-Cactus-Cryptid');
});
});
});
+1 -6
View File
@@ -1,12 +1,7 @@
import { STRING_ERROR_MSG, STRING_DOES_NOT_EXIST_MSG } from '../helpers/content.helper';
import { STRING_DOES_NOT_EXIST_MSG } from '../helpers/content.helper';
import translator from '../../website/common/script/content/translation';
describe('Translator', () => {
it('returns error message if string is not properly formatted', () => {
const improperlyFormattedString = translator('petName', { attr: 0 })();
expect(improperlyFormattedString).to.match(STRING_ERROR_MSG);
});
it('returns an error message if string does not exist', () => {
const stringDoesNotExist = translator('stringDoesNotExist')();
expect(stringDoesNotExist).to.match(STRING_DOES_NOT_EXIST_MSG);
+2 -2
View File
@@ -1,8 +1,8 @@
import i18n from '../../website/common/script/i18n';
import './globals.helper';
import { translations } from '../../website/server/libs/i18n';
import { contentTranslations } from '../../website/server/libs/i18n';
i18n.translations = translations;
i18n.translations = contentTranslations;
export const STRING_ERROR_MSG = /^Error processing the string ".*". Please see Help > Report a Bug.$/;
export const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/;
+1
View File
@@ -21,6 +21,7 @@ export async function getProperty (collectionName, id, path) {
// Specifically helpful for the GET /groups tests,
// resets the db to an empty state and creates a tavern document
export async function resetHabiticaDB () {
console.info('Resetting Habitica DB');
const groups = mongoose.connection.db.collection('groups');
const users = mongoose.connection.db.collection('users');
return mongoose.connection.dropDatabase()
+1 -1
View File
@@ -32,6 +32,6 @@
<script type="text/javascript" src="//cloudfront.loggly.com/js/loggly.tracker-latest.min.js" async></script>
<!-- Translations -->
<script type='text/javascript' src='/api/v4/i18n/browser-script' vite-ignore></script>
<script type='text/javascript' src='/api/v4/i18n/core' vite-ignore></script>
</body>
</html>
+4979 -2777
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"serve": "vite",
"serve:docker": "npx vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest run",
@@ -27,12 +28,13 @@
"eslint-config-habitrpg": "6.2.0",
"eslint-plugin-mocha": "5.3.0",
"eslint-plugin-vue": "7.20.0",
"habitica-markdown": "^3.0.0",
"habitica-markdown": "^4.0.0",
"hellojs": "^1.20.0",
"intro.js": "^7.2.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"markdown-it": "^14.0.0",
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"nconf": "^0.12.1",
"sass": "^1.63.4",
+5
View File
@@ -229,6 +229,11 @@ export default {
}
return Promise.resolve(error);
}
if (error.response.status === 404
&& error.response.config.method === 'get'
&& error.response.config.url.indexOf('/api/v4/groups/party') !== -1) {
return Promise.reject(error);
}
}
const errorData = error.response.data;
+2 -123
View File
@@ -1,57 +1,3 @@
.quest_lostMasterclasser4 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_lostMasterclasser4.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_windup {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_windup.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_solarSystem {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_solarSystem.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_virtualpet {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_virtualpet.gif") no-repeat;
width: 219px;
height: 219px;
}
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup,
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid {
width: 68px;
height: 68px;
}
.Pet_HatchingPotion_Dessert {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Dessert.gif") no-repeat;
}
.Pet_HatchingPotion_Veggie {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Veggie.gif") no-repeat;
}
.Pet_HatchingPotion_Windup {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Windup.gif") no-repeat;
}
.Pet_HatchingPotion_VirtualPet {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_VirtualPet.gif") no-repeat;
}
.Pet_HatchingPotion_Fungi {
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;
@@ -80,6 +26,7 @@
margin-left: -3px;
margin-top: -18px;
}
.slim_armor_special_0, .broad_armor_special_0, .shield_special_0 {
width: 90px;
height: 90px;
@@ -87,7 +34,6 @@
/* Critical */
.weapon_special_critical {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_critical.gif") no-repeat;
width: 90px;
height: 90px;
margin-left:-12px;
@@ -98,6 +44,7 @@
.weapon_special_1 {
margin-left: -12px;
}
.broad_armor_special_1, .slim_armor_special_1, .head_special_1 {
width: 90px;
height: 90px;
@@ -106,36 +53,15 @@
.back_special_heroicAureole {
width: 114px;
height: 90px;
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_special_heroicAureole.gif") no-repeat;
}
.head_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-ShadeHelmet.gif") no-repeat;
}
.head_special_1 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/ContributorOnly-Equip-CrystalHelmet.gif") no-repeat;
margin-top: 3px;
}
.broad_armor_special_0,.slim_armor_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-ShadeArmor.gif") no-repeat;
}
.broad_armor_special_1,.slim_armor_special_1 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/ContributorOnly-Equip-CrystalArmor.gif") no-repeat;
}
.shield_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Shield-TormentedSkull.gif") no-repeat;
}
.weapon_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Weapon-DarkSoulsBlade.gif") no-repeat;
}
.Pet-Wolf-Cerberus {
width: 105px;
height: 72px;
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Pet-CerberusPup.gif") no-repeat;
}
.broad_armor_special_ks2019, .slim_armor_special_ks2019, .eyewear_special_ks2019, .head_special_ks2019, .shield_special_ks2019 {
@@ -143,36 +69,17 @@
height: 120px;
}
.broad_armor_special_ks2019, .slim_armor_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonArmor.gif") no-repeat;
}
.eyewear_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonVisor.gif") no-repeat;
}
.head_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonHelm.gif") no-repeat;
}
.shield_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonShield.gif") no-repeat;
}
.weapon_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonGlaive.gif") no-repeat;
width: 120px;
height: 120px;
}
.Pet-Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Pet-Gryphatrice.gif") no-repeat;
width: 81px;
height: 99px;
}
.Pet-Gryphatrice-Jubilant {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Gryphatrice-Jubilant.gif") no-repeat;
width: 81px;
height: 96px;
}
@@ -182,39 +89,11 @@
height: 135px;
}
.Mount_Head_Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Head-Gryphatrice.gif") no-repeat;
}
.Mount_Body_Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Body-Gryphatrice.gif") no-repeat;
}
.Mount_Head_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Head_Dragon-Hydra.gif") no-repeat;
}
.Mount_Body_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Body_Dragon-Hydra.gif") no-repeat;
}
.background_airship, .background_clocktower, .background_steamworks {
width: 141px;
height: 147px;
}
.background_airship {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_airship.gif") no-repeat;
}
.background_clocktower {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_clocktower.gif") no-repeat;
}
.background_steamworks {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_steamworks.gif") no-repeat;
}
[class*="Mount_Head_"],
[class*="Mount_Body_"] {
margin-top:18px; /* Sprite accommodates 105x123 box */
@@ -1060,6 +1060,11 @@
width: 141px;
height: 147px;
}
.background_elven_citadel {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_elven_citadel.png');
width: 141px;
height: 147px;
}
.background_enchanted_music_room {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_enchanted_music_room.png');
width: 141px;
@@ -1796,6 +1801,11 @@
width: 141px;
height: 147px;
}
.background_on_a_strange_planet {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_on_a_strange_planet.png');
width: 141px;
height: 147px;
}
.background_on_tree_branch {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_on_tree_branch.png');
width: 141px;
@@ -1931,6 +1941,11 @@
width: 141px;
height: 147px;
}
.background_riding_a_comet {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_riding_a_comet.png');
width: 141px;
height: 147px;
}
.background_rime_ice {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_rime_ice.png');
width: 141px;
@@ -2427,6 +2442,11 @@
width: 141px;
height: 147px;
}
.background_waterfall_with_rainbow {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_waterfall_with_rainbow.png');
width: 141px;
height: 147px;
}
.background_wedding_arch {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_wedding_arch.png');
width: 141px;
@@ -29800,6 +29820,11 @@
width: 114px;
height: 90px;
}
.broad_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_handstandOutfit.png');
width: 114px;
height: 90px;
}
.broad_armor_armoire_hattersSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_hattersSuit.png');
width: 114px;
@@ -30075,6 +30100,11 @@
width: 114px;
height: 90px;
}
.broad_armor_armoire_softYellowSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_softYellowSuit.png');
width: 114px;
height: 90px;
}
.broad_armor_armoire_springPetalYukata {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_springPetalYukata.png');
width: 114px;
@@ -30385,6 +30415,11 @@
width: 114px;
height: 90px;
}
.head_armoire_floppyYellowHat {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_floppyYellowHat.png');
width: 114px;
height: 90px;
}
.head_armoire_flutteryWig {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_flutteryWig.png');
width: 114px;
@@ -30705,6 +30740,11 @@
width: 114px;
height: 90px;
}
.head_armoire_verdantArmingCap {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_verdantArmingCap.png');
width: 114px;
height: 90px;
}
.head_armoire_vermilionArcherHelm {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_vermilionArcherHelm.png');
width: 90px;
@@ -31120,6 +31160,11 @@
width: 114px;
height: 90px;
}
.shield_armoire_softYellowPillow {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_softYellowPillow.png');
width: 114px;
height: 90px;
}
.shield_armoire_spanishGuitar {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_spanishGuitar.png');
width: 114px;
@@ -31170,6 +31215,11 @@
width: 114px;
height: 90px;
}
.shield_armoire_verdantBanner {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_verdantBanner.png');
width: 114px;
height: 90px;
}
.shield_armoire_vikingShield {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_vikingShield.png');
width: 90px;
@@ -31440,6 +31490,11 @@
width: 114px;
height: 90px;
}
.slim_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_handstandOutfit.png');
width: 114px;
height: 90px;
}
.slim_armor_armoire_hattersSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_hattersSuit.png');
width: 114px;
@@ -31715,6 +31770,11 @@
width: 114px;
height: 90px;
}
.slim_armor_armoire_softYellowSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_softYellowSuit.png');
width: 114px;
height: 90px;
}
.slim_armor_armoire_springPetalYukata {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_springPetalYukata.png');
width: 114px;
@@ -34125,11 +34185,21 @@
width: 114px;
height: 90px;
}
.back_mystery_202605 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_mystery_202605.png');
width: 114px;
height: 90px;
}
.broad_armor_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_mystery_202512.png');
width: 114px;
height: 90px;
}
.broad_armor_mystery_202604 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_mystery_202604.png');
width: 114px;
height: 90px;
}
.head_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202512.png');
width: 114px;
@@ -34140,11 +34210,31 @@
width: 114px;
height: 90px;
}
.head_mystery_202603 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202603.png');
width: 114px;
height: 90px;
}
.head_mystery_202604 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202604.png');
width: 114px;
height: 90px;
}
.shield_mystery_202605 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_mystery_202605.png');
width: 114px;
height: 90px;
}
.slim_armor_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_mystery_202512.png');
width: 114px;
height: 90px;
}
.slim_armor_mystery_202604 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_mystery_202604.png');
width: 114px;
height: 90px;
}
.weapon_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_mystery_202512.png');
width: 114px;
@@ -34155,6 +34245,11 @@
width: 114px;
height: 90px;
}
.weapon_mystery_202603 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_mystery_202603.png');
width: 114px;
height: 90px;
}
.back_mystery_201402 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_mystery_201402.png');
width: 90px;
@@ -36275,6 +36370,26 @@
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.broad_armor_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_springHealer.png');
width: 90px;
@@ -36595,6 +36710,26 @@
width: 114px;
height: 90px;
}
.head_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.head_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.head_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.head_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.head_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_springHealer.png');
width: 90px;
@@ -36780,6 +36915,21 @@
width: 114px;
height: 90px;
}
.shield_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.shield_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.shield_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.shield_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_springHealer.png');
width: 90px;
@@ -37015,6 +37165,26 @@
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.slim_armor_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_springHealer.png');
width: 90px;
@@ -37255,6 +37425,26 @@
width: 114px;
height: 90px;
}
.weapon_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.weapon_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.weapon_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.weapon_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.weapon_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_springHealer.png');
width: 90px;
@@ -53038,6 +53228,11 @@
width: 81px;
height: 99px;
}
.Pet-BearCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-BearCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-BearCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-BearCub-Amber.png');
width: 81px;
@@ -53528,6 +53723,11 @@
width: 81px;
height: 99px;
}
.Pet-Cactus-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Cactus-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Cactus-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Cactus-Amber.png');
width: 81px;
@@ -54318,6 +54518,11 @@
width: 81px;
height: 99px;
}
.Pet-Dragon-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Dragon-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Dragon-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Dragon-Amber.png');
width: 81px;
@@ -54813,6 +55018,11 @@
width: 81px;
height: 99px;
}
.Pet-FlyingPig-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-FlyingPig-Alien.png');
width: 81px;
height: 99px;
}
.Pet-FlyingPig-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-FlyingPig-Amber.png');
width: 81px;
@@ -55148,6 +55358,11 @@
width: 81px;
height: 99px;
}
.Pet-Fox-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Fox-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Fox-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Fox-Amber.png');
width: 81px;
@@ -55928,6 +56143,11 @@
width: 81px;
height: 99px;
}
.Pet-LionCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-LionCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-LionCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-LionCub-Amber.png');
width: 81px;
@@ -56533,6 +56753,11 @@
width: 81px;
height: 99px;
}
.Pet-PandaCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-PandaCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-PandaCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-PandaCub-Amber.png');
width: 81px;
@@ -57928,6 +58153,11 @@
width: 81px;
height: 99px;
}
.Pet-TigerCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-TigerCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-TigerCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-TigerCub-Amber.png');
width: 81px;
@@ -58573,6 +58803,11 @@
width: 81px;
height: 99px;
}
.Pet-Wolf-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Wolf-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Wolf-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Wolf-Amber.png');
width: 81px;
Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

@@ -58,6 +58,11 @@ h3.markdown {
img {
max-width: 100%;
}
.emoji-native {
font-size: 0.85em;
vertical-align: middle;
}
blockquote {
padding: 0 16px;
@@ -0,0 +1,5 @@
<svg width="330" height="80" viewBox="0 0 330 80" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M159.797 60.5502C165.534 61.8466 171.631 62.7536 178.221 63.1767C208.94 65.1492 233.733 56.6838 260.68 47.483C282.33 40.091 305.37 32.2243 333.99 28.9144L336 16.3018C260.81 7.08865 233.373 23.1672 205.362 39.5825C192.037 47.3908 178.583 55.2753 159.797 60.5502Z" fill="#BDA8FF"/>
<path d="M0 80L331.948 79.9998V29.1594C268.976 36.9871 233.03 66.6959 178.221 63.1767C112.951 58.9858 95.9516 7.31934 0.000104656 0L0 80Z" fill="#925CF3"/>
<path d="M203.54 40.6496C166.339 36.8525 141.531 39.6251 122.334 45.4666C133.94 51.8989 145.792 57.3851 159.797 60.5502C177.727 55.5155 190.801 48.1036 203.54 40.6496Z" fill="#D5C8FF"/>
</svg>

After

Width:  |  Height:  |  Size: 803 B

@@ -1,53 +1,228 @@
<template>
<b-modal
id="rebirth"
:title="$t('modalAchievement')"
size="md"
:hide-footer="true"
size="sm"
:hide-header="true"
>
<div class="modal-body">
<div class="col-12">
<!-- @TODO: +achievementAvatar('sun',0)--><achievement-avatar class="avatar" />
</div><div class="col-6 offset-3 text-center">
<div v-if="user.achievements.rebirthLevel < 100">
{{ $t('rebirthAchievement', {
number: user.achievements.rebirths,
level: user.achievements.rebirthLevel}) }}
</div><div v-if="user.achievements.rebirthLevel >= 100">
{{ $t('rebirthAchievement100', {number: user.achievements.rebirths}) }}
</div><br><button
class="btn btn-primary"
@click="close()"
>
{{ $t('huzzah') }}
</button>
<div
class="close-x"
@click.stop="close()"
>
<div
class="svg-icon svg-close"
v-html="icons.close"
></div>
</div>
<div class="content text-center">
<h2
v-once
class="header"
>
{{ $t('rebirthNewAchievement') }}
</h2>
<div class="d-flex align-items-center justify-content-center icon-area">
<div
v-once
class="svg-icon sparkles mirror"
v-html="icons.starGroup"
></div>
<Sprite
class="achievement-icon"
image-name="achievement-sun2x"
/>
<div
v-once
class="svg-icon sparkles"
v-html="icons.starGroup"
></div>
</div>
</div><achievement-footer />
<p class="subtitle">
{{ $t('rebirthNewAdventure') }}
</p>
<p
class="description"
v-html="achievementText"
></p>
<p
v-once
class="stack-info"
>
{{ $t('rebirthStackInfo') }}
</p>
<button
v-once
class="btn btn-primary"
@click="close()"
>
{{ $t('onwards') }}
</button>
</div>
<div
slot="modal-footer"
class="footer-wave"
v-html="icons.purpleWaves"
></div>
</b-modal>
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
<style lang="scss">
@import '@/assets/scss/colors.scss';
#rebirth {
.modal-dialog {
width: 330px;
}
.modal-content {
border: none;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 28px 0 rgba($black, 0.24), 0 10px 10px 0 rgba($black, 0.28);
}
.modal-body {
padding: 0;
}
.modal-footer {
padding: 0;
border-top: none;
border-radius: 0;
margin: 0;
line-height: 0;
}
}
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.content {
padding: 24px 24px 0;
}
.header {
font-size: 1.25rem;
line-height: 1.4;
color: $purple-200;
margin-top: 8px;
margin-bottom: 16px;
}
.icon-area {
margin-bottom: 16px;
}
.sparkles {
width: 40px;
height: 64px;
&.mirror {
transform: scaleX(-1);
}
}
.close-x {
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
z-index: 2;
&:hover .svg-close {
opacity: 0.75;
}
.svg-close {
width: 16px;
height: 16px;
opacity: 0.5;
transition: opacity 0.2s ease;
pointer-events: none;
}
}
.achievement-icon {
margin: 0 24px;
}
.subtitle {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-style: normal;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
margin-bottom: 12px;
color: $gray-10;
}
.description {
font-size: 0.875rem;
line-height: 1.71;
margin-bottom: 12px;
color: $gray-50;
}
.stack-info {
font-size: 0.875rem;
line-height: 1.71;
color: $gray-50;
margin-bottom: 24px;
}
.btn-primary {
margin-bottom: 8px;
}
.footer-wave {
width: 100%;
::v-deep svg {
display: block;
width: calc(100% + 8px);
height: auto;
margin: 0 -4px -4px;
}
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import closeIcon from '@/assets/svg/close.svg?raw';
import Sprite from '@/components/ui/sprite';
import starGroup from '@/assets/svg/star-group.svg?raw';
import purpleWaves from '@/assets/svg/purple-waves.svg?raw';
import { mapState } from '@/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
Sprite,
},
data () {
return {
icons: Object.freeze({
starGroup,
purpleWaves,
close: closeIcon,
}),
};
},
computed: {
...mapState({ user: 'user.data' }),
achievementText () {
const rebirths = this.user.achievements.rebirths || 0;
const level = this.user.achievements.rebirthLevel || 0;
if (level >= 100) {
return this.$t('rebirthAchievement100', { number: rebirths, level });
}
if (rebirths === 1) {
return this.$t('rebirthAchievement', { number: rebirths, level });
}
return this.$t('rebirthAchievementPlural', { number: rebirths, level });
},
},
methods: {
close () {
@@ -1,41 +1,186 @@
<template>
<b-modal
id="rebirth-enabled"
:title="$t('rebirthNew')"
size="md"
:hide-footer="true"
size="sm"
:hide-header="true"
>
<div class="modal-body">
<div class="col-12">
<div class="rebirth_orb"></div>
<p>
<span>{{ $t('rebirthUnlock') }}</span>
</p>
</div>
<div
class="close-x"
@click.stop="close()"
>
<div
class="svg-icon svg-close"
v-html="icons.close"
></div>
</div>
<div class="modal-footer">
<div class="col-12 text-center">
<button
class="btn btn-primary"
@click="close()"
<div class="content text-center">
<h2
v-once
class="header"
>
{{ $t('rebirthUnlockedNewItem') }}
</h2>
<div class="d-flex align-items-center justify-content-center icon-area">
<div
v-once
class="svg-icon sparkles mirror"
v-html="icons.starGroup"
></div>
<img
class="orb-icon"
src="@/assets/images/rebirth-orb.png"
alt="Orb of Rebirth"
>
{{ $t('close') }}
</button>
<div
v-once
class="svg-icon sparkles"
v-html="icons.starGroup"
></div>
</div>
<p
v-once
class="subtitle"
>
{{ $t('rebirthUnlockedOrb') }}
</p>
<p
v-once
class="description"
>
{{ $t('rebirthUnlockedDesc') }}
</p>
<button
v-once
class="btn btn-primary"
@click="close()"
>
{{ $t('onwards') }}
</button>
</div>
<div
slot="modal-footer"
class="clearfix"
></div>
</b-modal>
</template>
<style scoped>
.rebirth_orb {
margin: 0 auto;
<style lang="scss">
@import '@/assets/scss/colors.scss';
#rebirth-enabled {
.modal-dialog {
width: 330px;
}
.modal-content {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 28px 0 rgba($black, 0.24), 0 10px 10px 0 rgba($black, 0.28);
}
.modal-body {
padding: 0;
}
.modal-footer {
padding: 0;
border-top: none;
}
}
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.content {
padding: 24px 24px 0;
}
.header {
font-size: 1.25rem;
line-height: 1.4;
color: $purple-200;
margin-top: 8px;
margin-bottom: 12px;
}
.icon-area {
margin-bottom: 12px;
}
.sparkles {
width: 40px;
height: 64px;
&.mirror {
transform: scaleX(-1);
}
}
.close-x {
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
z-index: 2;
&:hover .svg-close {
opacity: 0.75;
}
.svg-close {
width: 16px;
height: 16px;
opacity: 0.5;
transition: opacity 0.2s ease;
pointer-events: none;
}
}
.orb-icon {
width: 62px;
height: 62px;
margin: 0 24px;
image-rendering: pixelated;
}
.subtitle {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-style: normal;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
margin-bottom: 12px;
color: $gray-50;
}
.description {
font-size: 0.875rem;
line-height: 1.71;
margin-bottom: 24px;
color: $gray-100;
}
.btn-primary {
margin-bottom: 24px;
}
</style>
<script>
import closeIcon from '@/assets/svg/close.svg?raw';
import starGroup from '@/assets/svg/star-group.svg?raw';
import { mapState } from '@/libs/store';
export default {
data () {
return {
icons: Object.freeze({
starGroup,
close: closeIcon,
}),
};
},
computed: {
...mapState({ user: 'user.data' }),
},
@@ -422,6 +422,32 @@
class="btn btn-secondary"
@click="makeAdmin()"
>Make Admin</a>
<div class="d-flex align-items-center mt-2">
<input
v-model.number="partyChatCount"
type="number"
min="1"
class="form-control form-control-sm mr-2"
style="width: 80px;"
>
<a
class="btn btn-secondary"
@click="seedPartyChat()"
>Send Party Chat Messages</a>
</div>
<div class="d-flex align-items-center mt-2">
<input
v-model.number="inboxCount"
type="number"
min="1"
class="form-control form-control-sm mr-2"
style="width: 80px;"
>
<a
class="btn btn-secondary"
@click="seedInbox()"
>Send Inbox Messages</a>
</div>
</div>
</div>
</div>
@@ -796,6 +822,8 @@ export default {
DEBUG_ENABLED,
TIME_TRAVEL_ENABLED,
lastTimeJump: null,
partyChatCount: 450,
inboxCount: 450,
};
},
computed: {
@@ -914,6 +942,35 @@ export default {
// Reload the website then go to Help > Admin Panel to set contributor level, etc.');
// @TODO: sync()
},
async seedPartyChat () {
try {
const count = this.partyChatCount;
if (!Number.isInteger(count) || count < 1) {
window.alert('Please enter a positive integer'); // eslint-disable-line no-alert
return;
}
await axios.post('/api/v4/debug/seed-party-chat', { messageCount: count });
window.alert(`Successfully sent ${count} messages to your party chat!`); // eslint-disable-line no-alert
} catch (e) {
window.alert(e.response?.data?.message || 'Error sending party chat messages'); // eslint-disable-line no-alert
}
},
async seedInbox () {
try {
const count = this.inboxCount;
if (!Number.isInteger(count) || count < 1) {
window.alert('Please enter a positive integer'); // eslint-disable-line no-alert
return;
}
await axios.post('/api/v4/debug/seed-inbox', { messageCount: count });
window.alert(`Successfully sent ${count} messages to your inbox!`); // eslint-disable-line no-alert
} catch (e) {
window.alert(e.response?.data?.message || 'Error sending inbox messages'); // eslint-disable-line no-alert
}
},
donate () {
this.$root.$emit('bv::show::modal', 'buy-gems', { alreadyTracked: true });
},
showBailey () {
this.$root.$emit('bv::show::modal', 'new-stuff');
},
+5 -4
View File
@@ -321,10 +321,11 @@ export default {
return null;
},
petClass () {
const foolEvent = this.currentEventList?.find(event => event.aprilFools && moment()
.isBetween(event.start, event.end));
if (foolEvent) {
return this.foolPet(this.member.items.currentPet, foolEvent.aprilFools);
const substitutionEvent = this.currentEventList?.find(event => event.spriteSubstitutions
&& moment().isBetween(event.start, event.end));
if (substitutionEvent && substitutionEvent.spriteSubstitutions.pets) {
return this.foolPet(`Pet-${this.member.items.currentPet}`,
substitutionEvent.spriteSubstitutions.pets);
}
if (this.member?.items.currentPet) return `Pet-${this.member.items.currentPet}`;
return '';
@@ -12,23 +12,39 @@
<label>
<strong v-once>{{ $t('name') }} *</strong>
</label>
<b-form-input
<input
ref="nameInput"
v-model="workingChallenge.name"
class="form-control"
type="text"
:placeholder="$t('challengeNamePlaceholder')"
@keydown="enableSubmit"
/>
@focus="setActiveField('name')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
<div class="form-group">
<label>
<strong v-once>{{ $t('shortName') }} *</strong>
</label>
<b-form-input
<input
ref="shortNameInput"
v-model="workingChallenge.shortName"
class="form-control"
type="text"
:placeholder="$t('shortNamePlaceholder')"
@keydown="enableSubmit"
/>
@focus="setActiveField('shortName')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
<div class="form-group">
<label>
@@ -40,10 +56,17 @@
{{ $t('charactersRemaining', {characters: charactersRemaining}) }}
</div>
<textarea
ref="summaryTextarea"
v-model="workingChallenge.summary"
class="summary-textarea form-control"
:placeholder="$t('challengeSummaryPlaceholder')"
@keydown="enableSubmit"
@focus="setActiveField('summary')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
></textarea>
</div>
<div class="form-group">
@@ -55,11 +78,26 @@
class="float-right"
></a>
<textarea
ref="descriptionTextarea"
v-model="workingChallenge.description"
class="description-textarea form-control"
:placeholder="$t('challengeDescriptionPlaceholder')"
@keydown="enableSubmit"
@focus="setActiveField('description')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
></textarea>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="activeFieldText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
<div
v-if="creating"
@@ -280,12 +318,17 @@ import { TAVERN_ID, MIN_SHORTNAME_SIZE_FOR_CHALLENGES, MAX_SUMMARY_SIZE_FOR_CHAL
import CategoryOptions from '@/../../common/script/content/categoryOptions';
import markdownDirective from '@/directives/markdown';
import { userStateMixin } from '../../mixins/userState';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
components: {
emojiAutoComplete,
},
directives: {
markdown: markdownDirective,
},
mixins: [userStateMixin],
mixins: [userStateMixin, autoCompleteHelperMixin],
props: ['groupId'],
data () {
const categoryOptions = CategoryOptions;
@@ -319,9 +362,14 @@ export default {
categoriesHashByKey,
loading: false,
groups: [],
textbox: null,
activeField: 'name',
};
},
computed: {
activeFieldText () {
return this.workingChallenge[this.activeField] || '';
},
creating () {
return !this.workingChallenge.id;
},
@@ -589,6 +637,29 @@ export default {
toggleCategorySelect () {
this.showCategorySelect = !this.showCategorySelect;
},
setActiveField (field) {
this.activeField = field;
const refMap = {
name: 'nameInput',
shortName: 'shortNameInput',
summary: 'summaryTextarea',
description: 'descriptionTextarea',
};
this.textbox = this.$refs[refMap[field]] || null;
},
onFieldKeydown (e) {
this.enableSubmit();
this.autoCompleteMixinUpdateCarretPosition(e);
},
selectedAutocomplete (newText, newCaret) {
this.workingChallenge[this.activeField] = newText;
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
enableSubmit: throttle(function enableSubmit () {
/* Enables the submit button if it was disabled */
if (this.loading) {
@@ -0,0 +1,282 @@
<template>
<div
v-if="searchResults.length > 0"
class="autocomplete-selection"
:style="autocompleteStyle"
>
<div
v-for="result in searchResults"
:key="result.shortcode"
class="autocomplete-results d-flex align-items-center"
:class="{'hover-background': result.hover}"
@click="select(result)"
@mouseenter="setHover(result)"
@mouseleave="resetSelection()"
>
<img
v-if="result.imageUrl"
class="emoji-img"
:src="result.imageUrl"
:alt="result.shortcode"
>
<span
v-else
class="emoji-char"
>{{ result.emoji }}</span>
<span
class="shortcode ml-2"
:class="{'hover-foreground': result.hover}"
>:{{ result.shortcode }}:</span>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.autocomplete-results {
padding: .5em;
}
.autocomplete-selection {
box-shadow: 1px 1px 1px #efefef;
}
.hover-background {
background-color: rgba(213, 200, 255, 0.32);
cursor: pointer;
}
.hover-foreground {
color: $purple-300 !important;
}
.emoji-char {
font-size: 20px;
line-height: 1;
}
.emoji-img {
height: 20px;
width: 20px;
}
.shortcode {
color: $gray-200;
font-size: 14px;
}
</style>
<script>
import habiticaMarkdown from 'habitica-markdown';
export default {
props: ['text', 'caretPosition', 'coords', 'textbox'],
data () {
return {
colonRegex: /:([a-zA-Z0-9_+]*)$/,
currentSearch: '',
searchActive: false,
searchResults: [],
selected: null,
emojiList: [],
renderTick: 0,
internalCoords: { TOP: 0, LEFT: 0 },
};
},
computed: {
autocompleteStyle () {
// eslint-disable-next-line no-unused-vars
const _tick = this.renderTick;
const isTextarea = this.textbox.tagName === 'TEXTAREA';
const dropdownPA = (this.$el && this.$el.nodeType === 1) ? this.$el.offsetParent : null;
const textboxOP = this.textbox.offsetParent;
const needsRectCalc = dropdownPA && textboxOP && dropdownPA !== textboxOP;
let top;
let left;
const caretLeft = this.internalCoords.LEFT - (this.textbox.scrollLeft || 0);
if (needsRectCalc) {
const textboxRect = this.textbox.getBoundingClientRect();
const parentRect = dropdownPA.getBoundingClientRect();
const parentScrollTop = dropdownPA.scrollTop || 0;
if (isTextarea) {
const computedStyle = window.getComputedStyle(this.textbox);
const lineHeight = parseFloat(computedStyle.lineHeight)
|| (parseFloat(computedStyle.fontSize) * 1.4);
const caretTopInTextbox = this.internalCoords.TOP
- (this.textbox.scrollTop || 0) + lineHeight;
const clamped = Math.min(Math.max(caretTopInTextbox, 0), this.textbox.offsetHeight);
top = (textboxRect.top - parentRect.top) + parentScrollTop + clamped + 2;
} else {
top = (textboxRect.bottom - parentRect.top) + parentScrollTop + 2;
}
left = (textboxRect.left - parentRect.left) + caretLeft;
} else {
if (isTextarea) {
const computedStyle = window.getComputedStyle(this.textbox);
const lineHeight = parseFloat(computedStyle.lineHeight)
|| (parseFloat(computedStyle.fontSize) * 1.4);
const caretTopInTextbox = this.internalCoords.TOP
- (this.textbox.scrollTop || 0) + lineHeight;
const clamped = Math.min(Math.max(caretTopInTextbox, 0), this.textbox.offsetHeight);
top = this.textbox.offsetTop + clamped + 2;
} else {
top = this.textbox.offsetTop + this.textbox.offsetHeight + 2;
}
left = this.textbox.offsetLeft + caretLeft;
}
return {
top: `${top}px`,
left: `${left}px`,
position: 'absolute',
minWidth: '150px',
zIndex: 100,
backgroundColor: 'white',
};
},
},
watch: {
searchResults (results, oldResults) {
if (results.length > 0 && (!oldResults || oldResults.length === 0)) {
this.$nextTick(() => {
this.renderTick += 1;
});
}
},
text (newText, prevText) {
if (!this.textbox) return;
this._measureCaretCoords();
const delCharsBool = prevText.length > newText.length;
const caretPosition = this.textbox.selectionEnd;
const lastFocusChar = delCharsBool ? prevText[caretPosition] : newText[caretPosition - 1];
if (
newText.length === 0
|| (lastFocusChar === ':' && delCharsBool)
) {
this.cancel();
} else {
if (lastFocusChar === ':') this.searchActive = true;
if (this.searchActive) {
this.searchResults = this.solveSearchResults(newText.substring(0, caretPosition));
}
}
},
},
created () {
const defs = habiticaMarkdown.emojiDefs;
if (!defs) return;
const customEmojis = habiticaMarkdown.customEmojis || {};
const list = [];
const keys = Object.keys(defs);
keys.sort();
for (const key of keys) {
const entry = { shortcode: key, emoji: defs[key], hover: false };
if (customEmojis[key]) {
entry.imageUrl = customEmojis[key];
}
list.push(entry);
}
this.emojiList = list;
},
methods: {
solveSearchResults (textFocus) {
const regexRes = this.colonRegex.exec(textFocus);
if (!regexRes) {
this.cancel();
return [];
}
this.currentSearch = regexRes[1];
if (this.currentSearch.length === 0) return [];
const lowerSearch = this.currentSearch.toLowerCase();
return this.emojiList
.filter(entry => entry.shortcode.startsWith(lowerSearch))
.slice(0, 6)
.map(entry => ({ ...entry, hover: false }));
},
select (result) {
const { text } = this;
const targetName = `${result.shortcode}: `;
const oldCaret = this.caretPosition;
const escapedSearch = this.currentSearch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
let newText = text.substring(0, this.caretPosition)
.replace(new RegExp(`${escapedSearch}$`), targetName);
const newCaret = newText.length;
newText += text.substring(oldCaret, text.length);
this.$emit('select', newText, newCaret);
this.cancel();
},
setHover (result) {
this.resetSelection();
result.hover = true;
},
clearHover () {
for (const selection of this.searchResults) {
selection.hover = false;
}
},
resetSelection () {
this.clearHover();
this.selected = null;
},
selectNext () {
if (this.searchResults.length > 0) {
this.clearHover();
this.selected = this.selected === null
? 0
: (this.selected + 1) % this.searchResults.length;
this.searchResults[this.selected].hover = true;
}
},
selectPrevious () {
if (this.searchResults.length > 0) {
this.clearHover();
this.selected = this.selected === null
? this.searchResults.length - 1
: (this.selected - 1 + this.searchResults.length) % this.searchResults.length;
this.searchResults[this.selected].hover = true;
}
},
makeSelection () {
if (this.searchResults.length > 0 && this.selected !== null) {
const result = this.searchResults[this.selected];
this.select(result);
}
},
_measureCaretCoords () {
const el = this.textbox;
const caretPosition = el.selectionEnd;
const div = document.createElement('div');
const span = document.createElement('span');
const copyStyle = getComputedStyle(el);
[].forEach.call(copyStyle, prop => {
div.style[prop] = copyStyle[prop];
});
div.style.position = 'absolute';
div.style.visibility = 'hidden';
document.body.appendChild(div);
div.textContent = el.value.substr(0, caretPosition);
span.textContent = el.value.substr(caretPosition) || '.';
div.appendChild(span);
this.internalCoords = {
TOP: span.offsetTop,
LEFT: span.offsetLeft,
};
document.body.removeChild(div);
},
cancel () {
this.searchActive = false;
this.searchResults = [];
this.resetSelection();
},
},
};
</script>
@@ -187,7 +187,8 @@
</div>
</div>
<div
v-if="user.purchased.background.birthday_bash"
v-if="user.purchased.background.birthday_bash
|| user.purchased.background.on_a_strange_planet"
>
<div
class="row justify-content-center title-row mb-3"
@@ -0,0 +1,577 @@
<template>
<b-modal
id="group-plan-selection"
:hide-footer="true"
:hide-header="true"
size="md"
@show="loadData"
@hide="onHide"
>
<div class="selection-modal">
<div class="modal-header-row">
<h2 class="title">
{{ $t('chooseAnOption') }}
</h2>
<div class="header-actions">
<span
class="cancel-text"
@click="close"
>
{{ $t('cancel') }}
</span>
<button
class="btn btn-primary next-button"
:class="{ disabled: !selectedOption }"
:disabled="!selectedOption"
@click="continueFlow"
>
{{ $t('next') }}
</button>
</div>
</div>
<div
v-if="loading"
class="loading-container"
>
<div class="spinner-border text-secondary"></div>
</div>
<template v-else>
<div
v-if="hasUpgradeableGroups"
class="section-header"
>
{{ $t('upgradeExistingGroup') }}
</div>
<selectable-card
v-for="group in upgradeableGuilds"
:key="group._id"
class="option-card"
:selected="isSelected(group)"
@click="selectOption(group)"
>
<div class="option-content">
<div class="option-info">
<div class="option-name">
{{ group.name }}
</div>
<div class="option-members">
{{ formatMemberCount(group.memberCount) }}
</div>
<div class="option-label previously-upgraded">
<div
class="svg-icon sparkle-icon"
v-html="icons.sparkles"
></div>
{{ $t('previouslyUpgradedGroup') }}
</div>
</div>
<div class="option-price">
${{ calculatePrice(group.memberCount) }}.00/mo
</div>
</div>
</selectable-card>
<selectable-card
v-if="upgradeableParty"
class="option-card"
:class="{ 'has-pending-warning': partyPendingInviteCount > 0 }"
:selected="isSelected(upgradeableParty)"
@click="selectOption(upgradeableParty)"
>
<div class="option-content">
<div class="option-info">
<div class="option-name">
{{ upgradeableParty.name }}
</div>
<div class="option-members">
{{ formatMemberCount(upgradeableParty.memberCount) }}
<span
v-if="partyPendingInviteCount > 0"
class="pending-count"
>
{{ $t('pendingCount', { count: partyPendingInviteCount }) }}
</span>
</div>
<div
v-if="isPartyPreviouslyUpgraded"
class="option-label previously-upgraded"
>
<div
class="svg-icon sparkle-icon"
v-html="icons.sparkles"
></div>
{{ $t('previouslyUpgradedGroup') }}
</div>
<div
v-else
class="option-label your-party"
>
<div
class="svg-icon member-icon"
v-html="icons.member"
></div>
{{ $t('yourParty') }}
</div>
</div>
<div class="option-price">
${{ calculatePrice(upgradeableParty.memberCount) }}.00/mo
</div>
</div>
<div
v-if="partyPendingInviteCount > 0"
class="pending-warning-banner"
>
<div
class="svg-icon alert-icon"
v-html="icons.alert"
></div>
<span class="warning-text">{{ $t('upgradeCancelsPendingInvites') }}</span>
</div>
</selectable-card>
<div
v-if="hasUpgradeableGroups"
class="or-divider"
>
<div class="divider-line"></div>
<span class="or-text">{{ $t('or') }}</span>
<div class="divider-line"></div>
</div>
<selectable-card
class="option-card create-new"
:selected="selectedOption === 'new'"
@click="selectOption('new')"
>
<div class="option-content">
<div class="option-info">
<div class="option-name">
{{ $t('createNewGroup') }}
</div>
<div class="option-description">
{{ $t('inviteOthersForAdditional') }}
<span class="price-highlight">${{ perMemberPrice }}.00</span>
{{ $t('perMember') }}.
</div>
</div>
<div class="option-price">
${{ basePrice }}.00/mo
</div>
</div>
</selectable-card>
<div class="footer-note">
{{ $t('additionalMembersProrated') }}
</div>
</template>
</div>
</b-modal>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.selection-modal {
padding: 24px;
}
.modal-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.title {
font-family: 'Roboto Condensed', sans-serif;
font-weight: 700;
font-size: 20px;
line-height: 28px;
color: $purple-200;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
}
.cancel-text {
color: $blue-10;
font-size: 0.875rem;
margin-right: 16px;
cursor: pointer;
}
.next-button {
min-width: 64px;
&.disabled {
background-color: $gray-300;
border-color: $gray-300;
cursor: not-allowed;
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.section-header {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 14px;
line-height: 24px;
color: $gray-10;
margin-bottom: 12px;
}
.option-card {
margin-bottom: 12px;
::v-deep .option-name {
color: $gray-50;
}
&.selected ::v-deep .option-name {
color: $purple-200;
}
}
.pending-warning-banner {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
background-color: $yellow-50;
border-radius: 0 0 6px 6px;
margin: 16px -16px 0 -16px;
gap: 4px;
.selected & {
margin: 15px -15px 0 -15px;
}
.alert-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
::v-deep path {
fill: $gray-10;
}
}
.warning-text {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-10;
}
}
.option-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-left: 32px;
padding-right: 8px;
}
.option-info {
flex: 1;
}
.option-name {
font-family: 'Roboto', sans-serif;
font-size: 14px;
font-weight: 700;
line-height: 24px;
margin-bottom: 4px;
}
.option-members {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
margin-bottom: 8px;
.pending-count {
font-weight: 700;
color: $yellow-5;
}
}
.option-label {
display: flex;
align-items: center;
font-family: 'Roboto', sans-serif;
font-size: 12px;
line-height: 16px;
gap: 4px;
&.previously-upgraded {
font-weight: 700;
color: $blue-10;
}
&.your-party {
font-weight: 700;
color: $gray-100;
}
.svg-icon {
width: 14px;
height: 14px;
}
.sparkle-icon {
color: $blue-10;
}
.member-icon {
color: $gray-100;
::v-deep path {
fill: $gray-100;
stroke: $gray-100;
stroke-width: 0.5px;
}
}
}
.option-description {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
.price-highlight {
font-weight: 700;
}
}
.option-price {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 20px;
line-height: 24px;
color: $purple-200;
white-space: nowrap;
}
.or-divider {
display: flex;
align-items: center;
margin: 20px 0;
.divider-line {
flex: 1;
height: 1px;
background-color: $gray-500;
}
.or-text {
padding: 0 16px;
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 12px;
line-height: 16px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: $gray-100;
}
}
.create-new {
.option-name {
margin-bottom: 8px;
}
}
.footer-note {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
text-align: center;
margin-top: 16px;
margin-left: 24px;
margin-right: 24px;
}
</style>
<style lang="scss">
#group-plan-selection {
.modal-dialog {
max-width: 504px;
}
.modal-content {
border-radius: 8px;
box-shadow: 0 14px 28px 0 rgba(26, 24, 29, 0.24), 0 10px 10px 0 rgba(26, 24, 29, 0.28);
}
.modal-body {
padding: 0;
}
.option-card.has-pending-warning.selectable-card {
padding-bottom: 0;
}
}
</style>
<script>
import axios from 'axios';
import paymentsMixin from '@/mixins/payments';
import { mapState } from '@/libs/store';
import SelectableCard from '@/components/ui/selectableCard.vue';
import svgSparkles from '@/assets/svg/sparkles.svg?raw';
import svgMember from '@/assets/svg/member-icon.svg?raw';
import svgAlert from '@/assets/svg/for-css/alert.svg?raw';
export default {
components: {
SelectableCard,
},
mixins: [paymentsMixin],
data () {
return {
selectedOption: null,
userGuilds: [],
userParty: null,
activeGroupPlanIds: [],
loading: true,
basePrice: 9,
perMemberPrice: 3,
icons: Object.freeze({
sparkles: svgSparkles,
member: svgMember,
alert: svgAlert,
}),
partyPendingInviteCount: 0,
};
},
computed: {
...mapState({ user: 'user.data' }),
upgradeableGuilds () {
return this.userGuilds.filter(group => {
const leaderId = group.leader?._id || group.leader;
if (leaderId !== this.user._id) return false;
const purchased = group.purchased;
if (!purchased?.wasUpgraded) return false;
if (this.activeGroupPlanIds.includes(group._id)) return false;
if (!purchased.dateTerminated) return false;
return new Date(purchased.dateTerminated) < new Date();
});
},
upgradeableParty () {
if (!this.userParty) return null;
const leaderId = this.userParty.leader?._id || this.userParty.leader;
if (leaderId !== this.user._id) return null;
if (this.activeGroupPlanIds.includes(this.userParty._id)) return null;
return this.userParty;
},
hasUpgradeableGroups () {
return this.upgradeableGuilds.length > 0 || this.upgradeableParty !== null;
},
isPartyPreviouslyUpgraded () {
if (!this.userParty) return false;
const purchased = this.userParty.purchased;
if (!purchased?.wasUpgraded) return false;
if (!purchased.dateTerminated) return false;
return new Date(purchased.dateTerminated) < new Date();
},
},
methods: {
async loadData () {
this.loading = true;
this.selectedOption = null;
this.partyPendingInviteCount = 0;
try {
const [guildsResponse, partyResponse] = await Promise.all([
axios.get('/api/v4/groups', { params: { type: 'guilds', includeExpiredPlans: 'true' } }),
axios.get('/api/v4/groups/party').catch(() => ({ data: { data: null } })),
]);
this.userGuilds = guildsResponse.data.data || [];
this.userParty = partyResponse.data.data;
if (this.userParty) {
try {
const invitesResponse = await axios.get(`/api/v4/groups/${this.userParty._id}/invites`);
this.partyPendingInviteCount = invitesResponse.data.data?.length || 0;
} catch (e) {
this.partyPendingInviteCount = 0;
}
}
await this.$store.dispatch('guilds:getGroupPlans', true);
const groupPlans = this.$store.state.groupPlans?.data || [];
this.activeGroupPlanIds = groupPlans.map(g => g._id);
} catch (e) {
console.error('Error loading group data:', e);
}
this.loading = false;
this.$nextTick(() => {
if (this.upgradeableGuilds.length > 0) {
this.selectedOption = this.upgradeableGuilds[0];
} else if (this.upgradeableParty) {
this.selectedOption = this.upgradeableParty;
} else {
this.selectedOption = 'new';
}
});
},
selectOption (option) {
this.selectedOption = option;
},
isSelected (group) {
if (!this.selectedOption || this.selectedOption === 'new') return false;
return this.selectedOption._id === group._id;
},
calculatePrice (memberCount) {
return this.basePrice + (this.perMemberPrice * (memberCount - 1));
},
formatMemberCount (count) {
return count === 1 ? this.$t('oneMember') : this.$t('membersCount', { count });
},
continueFlow () {
if (!this.selectedOption) return;
const selection = this.selectedOption;
this.close();
if (selection === 'new') {
this.$root.$emit('bv::show::modal', 'create-group');
} else {
this.stripeGroup({ group: selection, upgrade: true });
}
},
close () {
this.$root.$emit('bv::hide::modal', 'group-plan-selection');
},
onHide () {
this.selectedOption = null;
},
},
};
</script>
@@ -41,6 +41,14 @@
:chat="group.chat"
@select="selectedAutocomplete"
/>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="newMessage"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
<community-guidelines />
<div class="row chat-actions">
@@ -90,6 +98,7 @@ import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants';
import externalLinks from '../../mixins/externalLinks';
import autocomplete from '../chat/autoComplete';
import emojiAutoComplete from '../chat/emojiAutoComplete';
import communityGuidelines from './communityGuidelines';
import chatMessages from '../chat/chatMessages';
import { mapState } from '@/libs/store';
@@ -102,6 +111,7 @@ export default {
},
components: {
autocomplete,
emojiAutoComplete,
communityGuidelines,
chatMessages,
},
+82 -57
View File
@@ -25,53 +25,61 @@
<div class="col-12 col-md-6">
<div class="row icon-row">
<div
class="item-with-icon"
class="item-with-icon p-2"
tabindex="0"
role="button"
@keyup.enter="showMemberModal()"
@click="showMemberModal()"
>
<div
v-if="group.memberCount > 1000"
class="svg-icon shield"
v-html="icons.goldGuildBadgeIcon"
></div>
<div
v-if="group.memberCount > 100 && group.memberCount < 999"
class="svg-icon shield"
v-html="icons.silverGuildBadgeIcon"
></div>
<div
v-if="group.memberCount < 100"
class="svg-icon shield"
v-html="icons.bronzeGuildBadgeIcon"
></div>
<span class="number">{{ group.memberCount | abbrNum }}</span>
<div
v-once
class="member-list label"
>
{{ $t('memberList') }}
<div class="box-content">
<div class="icon-number-row">
<div
v-if="group.memberCount > 1000"
class="svg-icon shield"
v-html="icons.goldGuildBadgeIcon"
></div>
<div
v-if="group.memberCount > 100 && group.memberCount < 999"
class="svg-icon shield"
v-html="icons.silverGuildBadgeIcon"
></div>
<div
v-if="group.memberCount < 100"
class="svg-icon shield"
v-html="icons.bronzeGuildBadgeIcon"
></div>
<span class="number">{{ group.memberCount | abbrNum }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('memberList') }}
</div>
</div>
</div>
<div v-if="!isParty">
<div
class="item-with-icon"
class="item-with-icon p-2"
tabindex="0"
role="button"
@keyup.enter="showGroupGems()"
@click="showGroupGems()"
>
<div
class="svg-icon gem"
v-html="icons.gem"
></div>
<span class="number">{{ group.balance * 4 }}</span>
<div
v-once
class="label"
>
{{ $t('guildBank') }}
<div class="box-content">
<div class="icon-number-row">
<div
class="svg-icon gem"
v-html="icons.gem"
></div>
<span class="number">{{ group.balance * 4 }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('guildBank') }}
</div>
</div>
</div>
</div>
@@ -128,35 +136,57 @@
}
.item-with-icon {
display: inline-block;
border-radius: 2px;
background-color: #ffffff;
background-color: $white;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
padding: 1em;
text-align: center;
min-width: 120px;
margin-left: 1em;
width: 120px;
height: 76px;
margin-right: 1rem;
text-align: center;
font-size: 20px;
vertical-align: bottom;
overflow: hidden;
position: relative;
&:last-of-type {
margin-left: 0.5rem;
.box-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.svg-icon.shield, .svg-icon.gem {
width: 28px;
height: auto;
margin: 0 auto;
.icon-number-row {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.1em;
.number {
font-size: 18px;
font-weight: normal;
margin-left: 0.2em;
}
}
.svg-icon {
width: 24px;
height: 24px;
display: inline-block;
vertical-align: bottom;
margin-right: 0.5em;
}
.number {
font-size: 22px;
font-weight: bold;
}
.label {
margin-top: .5em;
.details {
font-size: 11px;
color: $gray-200;
width: 100%;
padding: 0 4px;
line-height: 1.1;
word-break: break-word;
max-height: 2.2em;
overflow: visible;
}
}
@@ -215,11 +245,6 @@
.icon-row {
margin-top: 1em;
justify-content: flex-end;
.number {
font-size: 22px;
font-weight: bold;
}
}
.chat-row {
@@ -182,12 +182,10 @@ export default {
return 'GreyedOut';
},
imageName () {
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, foolEvent.aprilFools)}`;
const substitutionEvent = this.currentEventList?.find(event => moment()
.isBetween(event.start, event.end) && event.spriteSubstitutions);
if (this.isOwned() && substitutionEvent && substitutionEvent.spriteSubstitutions.pets) {
return `stable_${this.foolPet(`Pet-${this.item.key}`, substitutionEvent.spriteSubstitutions.pets)}`;
}
if (this.isOwned() || (this.mountOwned() && this.isHatchable())) {
@@ -10,6 +10,9 @@
>
<div class="modal-body">
<news-content ref="newsContent" />
<close-x
@close="dismissAlert()"
/>
</div>
<div class="modal-footer d-flex align-items-center pb-0">
@@ -30,12 +33,18 @@
</template>
<script>
import { mapState } from '@/libs/store';
import newsContent from './newsContent';
import closeX from '../ui/closeX.vue';
export default {
components: {
closeX,
newsContent,
},
computed: {
...mapState({ user: 'user.data' }),
},
methods: {
async onShow () {
this.$refs.newsContent.getPosts();
@@ -330,6 +330,7 @@ export default {
handledNotifications,
isInitialLoadComplete: false,
pendingRebirthNotification: null,
lastShownStreakCount: null, // Track last shown streak to prevent duplicates
};
},
computed: {
@@ -726,17 +727,24 @@ export default {
this.$root.$emit('habitica:won-challenge', notification);
break;
case 'REBIRTH_ACHIEVEMENT':
if (localStorage.getItem('show-rebirth-confirmation') === 'true') {
markAsRead = false;
} else if (!this.isInitialLoadComplete) {
this.pendingRebirthNotification = notification;
markAsRead = false;
} else {
this.playSound('Achievement_Unlocked');
this.$root.$emit('bv::show::modal', 'rebirth');
if (localStorage.getItem('show-rebirth-confirmation') !== 'true') {
if (!this.isInitialLoadComplete) {
this.pendingRebirthNotification = notification;
markAsRead = false;
} else {
this.playSound('Achievement_Unlocked');
this.$root.$emit('bv::show::modal', 'rebirth');
}
}
break;
case 'STREAK_ACHIEVEMENT':
// Client-side deduplication: prevent showing duplicate streak achievements
if (this.lastShownStreakCount === this.user.achievements.streak) {
// Same streak already shown, skip this notification
break;
}
this.lastShownStreakCount = this.user.achievements.streak;
this.text(`${this.$t('streaks')}: ${this.user.achievements.streak}`, () => {
this.$root.$emit('bv::show::modal', 'streak');
}, this.user.preferences.suppressModals.streak);
@@ -42,7 +42,7 @@
:hide-class-badge="true"
:with-background="true"
:override-avatar-gear="getAvatarOverrides(item)"
:sprites-margin="'0px auto 0px -24px'"
:sprites-margin="'0px auto 0px -2px'"
/>
</div>
<item
@@ -281,6 +281,11 @@
.badge-dialog {
left: -8px;
top: -8px;
.badge-pin {
width: 32px;
height: 32px;
}
}
.avatar {
@@ -903,8 +908,8 @@ export default {
purchaseGems () {
this.$root.$emit('bv::show::modal', 'buy-gems');
},
togglePinned () {
this.isPinned = this.$store.dispatch('user:togglePinnedItem', { type: this.item.pinType, path: this.item.path });
async togglePinned () {
this.isPinned = await this.$store.dispatch('user:togglePinnedItem', { type: this.item.pinType, path: this.item.path });
if (!this.isPinned) {
this.text(this.$t('unpinnedItem', { item: this.item.text }));
@@ -76,7 +76,21 @@
:empty-item="false"
:show-popover="Boolean(ctx.item.text)"
@click="selectItem(ctx.item)"
/>
>
<template
slot="itemBadge"
slot-scope="slotProps"
>
<span
class="badge-top"
@click.prevent.stop="togglePinned(slotProps.item)"
>
<pin-badge
:pinned="slotProps.item.pinned"
/>
</span>
</template>
</shop-item>
</template>
</item-rows>
</div>
@@ -108,6 +122,16 @@
}
</style>
<style lang="scss">
.market .badge-pin:not(.pinned) {
display: none;
}
.market .item:hover .badge-pin {
display: block;
}
</style>
<script>
import find from 'lodash/find';
import shops from '@/../../common/script/libs/shops';
@@ -118,7 +142,9 @@ import Checkbox from '@/components/ui/checkbox';
import FilterGroup from '@/components/ui/filterGroup';
import FilterSidebar from '@/components/ui/filterSidebar';
import ItemRows from '@/components/ui/itemRows';
import PinBadge from '@/components/ui/pinBadge';
import ShopItem from '../shopItem';
import pinUtils from '@/mixins/pinUtils';
export default {
components: {
@@ -126,8 +152,10 @@ export default {
FilterGroup,
FilterSidebar,
ItemRows,
PinBadge,
ShopItem,
},
mixins: [pinUtils],
data () {
return {
searchText: null,
@@ -184,8 +212,12 @@ export default {
methods: {
customizationsItems (options = {}) {
const { category, searchBy } = options;
return category.items.filter(item => !searchBy
|| item.text.toLowerCase().includes(searchBy));
return category.items
.filter(item => !searchBy || item.text.toLowerCase().includes(searchBy))
.map(item => ({
...item,
pinned: this.isPinned(item),
}));
},
emptyClick (identifier, event) {
if (event.target.tagName !== 'A') return;
@@ -180,6 +180,11 @@
.badge-dialog {
left: -8px;
top: -8px;
.badge-pin {
width: 32px;
height: 32px;
}
}
.modal-content {
@@ -498,8 +498,13 @@ export default {
await this.triggerGetWorldState();
this.currentEvent = _find(this.currentEventList, event => Boolean(event.season));
this.imageURLs.background = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_background.png)`;
this.imageURLs.npc = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_npc.png)`;
if (this.currentEvent.season === 'valentines') {
this.imageURLs.background = 'url(/static/npc/spring/seasonal_shop_opened_background.png)';
this.imageURLs.npc = 'url(/static/npc/spring/seasonal_shop_opened_npc.png)';
} else {
this.imageURLs.background = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_background.png)`;
this.imageURLs.npc = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_npc.png)`;
}
},
beforeDestroy () {
this.$root.$off('buyModal::boughtItem');
@@ -1,5 +1,6 @@
<template>
<div>
<group-plan-selection-modal />
<group-plan-creation-modal />
<div class="d-flex justify-content-center">
<div
@@ -315,10 +316,12 @@
import { setup as setupPayments } from '@/libs/payments';
import paymentsMixin from '../../mixins/payments';
import GroupPlanCreationModal from '../group-plans/groupPlanCreationModal.vue';
import GroupPlanSelectionModal from '../group-plans/groupPlanSelectionModal.vue';
export default {
components: {
GroupPlanCreationModal,
GroupPlanSelectionModal,
},
mixins: [paymentsMixin],
data () {
@@ -359,7 +362,7 @@ export default {
if (this.upgradingGroup._id) {
return this.stripeGroup({ group: this.upgradingGroup, upgrade: true });
}
return this.$root.$emit('bv::show::modal', 'create-group');
return this.$root.$emit('bv::show::modal', 'group-plan-selection');
},
},
};
+3 -19
View File
@@ -348,7 +348,6 @@
import throttle from 'lodash/throttle';
import isEmpty from 'lodash/isEmpty';
import draggable from 'vuedraggable';
import { shouldDo } from '@/../../common/script/cron';
import inAppRewards from '@/../../common/script/libs/inAppRewards';
import taskDefaults from '@/../../common/script/libs/taskDefaults';
import Task from './task';
@@ -482,25 +481,10 @@ export default {
return this.$t('addATask', { type });
},
badgeCount () {
// 0 means the badge will not be shown
// It is shown for the all and due views of dailies
// and for the active and scheduled views of todos.
if (this.type === 'todo' && this.activeFilter.label !== 'complete2') {
return this.taskList.length;
} if (this.type === 'daily') {
if (this.activeFilter.label === 'due') {
return this.taskList.length;
} if (this.activeFilter.label === 'all') {
return this.taskList
.reduce(
(count, t) => (!t.completed
&& shouldDo(new Date(), t, this.getUserPreferences) ? count + 1 : count),
0,
);
}
if (this.type === 'reward') {
return 0;
}
return 0;
return this.taskList.length;
},
},
watch: {
@@ -48,11 +48,19 @@
/>
<input
:ref="'checklistItem-' + $index"
v-model="item.text"
class="inline-edit-input checklist-item form-control"
type="text"
:disabled="disabled || disableEdit"
:class="summaryClass(item)"
@focus="setActiveItem($index)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
<span
v-if="!disabled && !disableEdit"
@@ -81,15 +89,30 @@
</span>
<input
ref="newChecklistInput"
v-model="newChecklistItem"
class="inline-edit-input checklist-item form-control"
type="text"
:placeholder="$t('newChecklistItem')"
@keypress.enter="setHasPossibilityOfIMEConversion(false)"
@focus="setActiveItem(-1)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="newChecklistEnterHandler($event)"
@keyup.enter="addChecklistItem($event, true)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
@blur="addChecklistItem($event, false)"
>
</div>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="activeFieldText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</b-collapse>
</div>
</template>
@@ -105,6 +128,8 @@ import chevronIcon from '@/assets/svg/chevron.svg?raw';
import gripIcon from '@/assets/svg/grip.svg?raw';
import checkbox from '@/components/ui/checkbox';
import lockableLabel from './lockableLabel';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
name: 'Checklist',
@@ -112,7 +137,9 @@ export default {
checkbox,
draggable,
lockableLabel,
emojiAutoComplete,
},
mixins: [autoCompleteHelperMixin],
props: {
disabled: {
type: Boolean,
@@ -133,6 +160,8 @@ export default {
showChecklist: true,
hasPossibilityOfIMEConversion: true,
newChecklistItem: null,
textbox: null,
activeItemIndex: -1,
icons: Object.freeze({
positive: positiveIcon,
destroy: deleteIcon,
@@ -141,6 +170,15 @@ export default {
}),
};
},
computed: {
activeFieldText () {
if (this.activeItemIndex === -1) {
return this.newChecklistItem || '';
}
const item = this.checklist[this.activeItemIndex];
return item ? item.text || '' : '';
},
},
methods: {
summaryClass (item) {
if (!this.disableEdit) return '';
@@ -179,6 +217,40 @@ export default {
this.checklist.splice(i, 1);
this.updateChecklist();
},
setActiveItem (index) {
this.activeItemIndex = index;
if (index === -1) {
this.textbox = this.$refs.newChecklistInput;
} else {
const refArr = this.$refs[`checklistItem-${index}`];
this.textbox = refArr ? refArr[0] || refArr : null;
}
},
newChecklistEnterHandler (e) {
const ac = this._getActiveAutocomplete();
if (ac && ac.selected !== null) {
e.preventDefault();
ac.makeSelection();
} else if (ac) {
ac.cancel();
this.setHasPossibilityOfIMEConversion(false);
} else {
this.setHasPossibilityOfIMEConversion(false);
}
},
selectedAutocomplete (newText, newCaret) {
if (this.activeItemIndex === -1) {
this.newChecklistItem = newText;
} else {
this.checklist[this.activeItemIndex].text = newText;
}
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
},
};
</script>
@@ -187,6 +259,7 @@ export default {
@import '@/assets/scss/colors.scss';
.checklist-component {
position: relative;
.chevron-flip {
transform: translateY(-5px) rotate(180deg);
@@ -9,12 +9,27 @@
@toggle="openOrClose($event)"
>
<b-dropdown-header>
<div class="mb-2">
<div class="mb-2 search-input-wrapper">
<b-form-input
ref="searchInput"
v-model="search"
type="text"
:placeholder="searchPlaceholder"
@keyup.enter="handleSubmit"
@focus="setTextbox"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keydown.enter="searchEnterHandler($event)"
@keydown.esc="searchEscHandler($event)"
/>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="search"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
@@ -41,7 +56,7 @@
v-if="addNew || availableToSelect.length > 0"
:class="{
'item-group': true,
'add-new': availableToSelect.length === 0 && search !== '',
'add-new': search !== '' && !hasExactMatch,
'scroll': availableToSelect.length > 5
}"
>
@@ -71,7 +86,7 @@
</b-dropdown-item-button>
<div
v-if="addNew"
v-if="addNew && search !== '' && !hasExactMatch"
class="hint"
>
{{ $t('pressEnterToAddTag', { tagName: search }) }}
@@ -94,6 +109,10 @@ $itemHeight: 2rem;
}
.select-multi {
.search-input-wrapper {
position: relative;
}
.dropdown-toggle {
padding-left: 0.75rem;
}
@@ -152,7 +171,8 @@ $itemHeight: 2rem;
max-height: #{5*$itemHeight};
&.add-new {
height: 30px;
min-height: 30px;
height: auto;
.hint {
display: block;
@@ -185,6 +205,8 @@ $itemHeight: 2rem;
import Vue from 'vue';
import MultiList from '@/components/tasks/modal-controls/multiList';
import markdownDirective from '@/directives/markdown';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
directives: {
@@ -192,7 +214,9 @@ export default {
},
components: {
MultiList,
emojiAutoComplete,
},
mixins: [autoCompleteHelperMixin],
props: {
addNew: {
type: Boolean,
@@ -221,6 +245,8 @@ export default {
wasTagAdded: false,
selected: this.selectedItems,
search: '',
textbox: null,
itemsAdded: [],
};
},
computed: {
@@ -248,6 +274,16 @@ export default {
return filteredItems;
},
hasExactMatch () {
const searchTerm = this.search.trim().toLowerCase();
if (!searchTerm) return false;
if (this.itemsAdded.indexOf(searchTerm) !== -1) return true;
if (this.availableToSelect.length === 0) return false;
if (this.availableToSelect[0].name.toLowerCase() === searchTerm) {
return true;
}
return false;
},
},
watch: {
selected () {
@@ -286,6 +322,7 @@ export default {
this.closeSelectPopup();
},
selectItem (item) {
if (!item) return;
this.selectedItems.push(item.id);
this.$emit('toggle', item.id);
this.preventHide = true;
@@ -312,12 +349,51 @@ export default {
this.closeSelectPopup();
}
},
setTextbox () {
const ref = this.$refs.searchInput;
this.textbox = ref ? (ref.$el || ref) : null;
},
searchEnterHandler (e) {
const ac = this._getActiveAutocomplete();
if (ac && ac.selected !== null) {
e.preventDefault();
e.stopPropagation();
ac.makeSelection();
} else {
if (ac) ac.cancel();
this.handleSubmit();
}
},
searchEscHandler (e) {
const ac = this._getActiveAutocomplete();
if (ac && ac.searchActive) {
e.preventDefault();
e.stopPropagation();
ac.cancel();
}
},
selectedAutocomplete (newText, newCaret) {
this.search = newText;
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
handleSubmit () {
if (!this.addNew) return;
const { search } = this;
this.$emit('addNew', search);
this.search = '';
// If there is a existing tag
if (this.hasExactMatch) {
this.selectItem(this.availableToSelect[0]);
this.search = '';
} else {
// Creating a new tag as there is no existing tag present
this.$emit('addNew', search);
this.itemsAdded.push(search.toLowerCase());
this.search = '';
}
},
},
};
@@ -70,6 +70,13 @@
spellcheck="true"
:disabled="challengeAccessRequired"
:placeholder="$t('addATitle')"
@focus="setActiveField('title')"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="titleEnterHandler($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
<div
@@ -92,11 +99,27 @@
</small>
</div>
<textarea
ref="notesTextarea"
v-model="task.notes"
class="form-control input-notes"
:class="cssClass('input')"
:placeholder="$t('addNotes')"
@focus="setActiveField('notes')"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
></textarea>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="activeFieldText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
</div>
<div
@@ -712,6 +735,7 @@
}
.task-modal-header {
position: relative;
color: $white;
width: 100%;
border-top-left-radius: 8px;
@@ -1160,6 +1184,8 @@ import lockableLabel from '@/components/tasks/modal-controls/lockableLabel';
import selectList from '@/components/ui/selectList';
import syncTask from '../../mixins/syncTask';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
import positiveIcon from '@/assets/svg/positive.svg?raw';
import negativeIcon from '@/assets/svg/negative.svg?raw';
@@ -1182,15 +1208,18 @@ export default {
toggleCheckbox,
lockableLabel,
selectList,
emojiAutoComplete,
},
directives: {
markdown: markdownDirective,
},
mixins: [syncTask],
mixins: [syncTask, autoCompleteHelperMixin],
// purpose is either create or edit, task is the task created or edited
props: ['task', 'purpose', 'challengeId', 'groupId'],
data () {
return {
textbox: null,
activeField: 'title',
showAssignedSelect: false,
newChecklistItem: null,
icons: Object.freeze({
@@ -1314,6 +1343,10 @@ export default {
selectedTags () {
return this.getTagsFor(this.task);
},
activeFieldText () {
if (!this.task) return '';
return this.activeField === 'title' ? (this.task.text || '') : (this.task.notes || '');
},
showStatAssignment () {
return this.task.type !== 'reward'
&& !this.groupId
@@ -1489,6 +1522,35 @@ export default {
},
focusInput () {
this.$refs.inputToFocus.focus();
this.setActiveField('title');
},
setActiveField (field) {
this.activeField = field;
if (field === 'title') {
this.textbox = this.$refs.inputToFocus;
} else {
this.textbox = this.$refs.notesTextarea;
}
},
titleEnterHandler (e) {
const ac = this._getActiveAutocomplete();
if (ac && ac.selected !== null) {
e.preventDefault();
ac.makeSelection();
} else if (ac) {
ac.cancel();
}
},
selectedAutocomplete (newText, newCaret) {
if (this.activeField === 'title') {
this.task.text = newText;
} else {
this.task.notes = newText;
}
this.$nextTick(() => {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
});
},
async addTag (name) {
const tagResult = await this.createTag({ name });
+75 -1
View File
@@ -80,9 +80,17 @@
v-html="icons.drag"
></div>
<input
:ref="'tagInput-' + tagIndex"
v-model="tag.name"
class="tag-edit-input inline-edit-input form-control"
type="text"
@focus="setActiveTag(tagIndex)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
<div
class="input-group-append"
@@ -100,11 +108,18 @@
class="col-6 dragSpace"
>
<input
ref="newTagInput"
v-model="newTag"
class="new-tag-item edit-tag-item inline-edit-input form-control"
type="text"
:placeholder="$t('newTag')"
@keydown.enter="addTag($event, tagsType.key)"
@focus="setActiveTag(-1)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="newTagEnterHandler($event, tagsType.key)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
</draggable>
@@ -134,6 +149,15 @@
</div>
</div>
</div>
<emoji-auto-complete
v-if="editingTags"
ref="emojiAutocomplete"
:text="activeTagText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedTagAutocomplete"
/>
<div class="filter-panel-footer clearfix">
<template v-if="editingTags === true">
<div class="text-center">
@@ -405,6 +429,8 @@ import dragIcon from '@/assets/svg/drag_indicator.svg?raw';
import { mapState, mapActions } from '@/libs/store';
import brokenTaskModal from './brokenTaskModal';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
components: {
@@ -414,10 +440,12 @@ export default {
spells,
brokenTaskModal,
draggable,
emojiAutoComplete,
},
directives: {
markdown,
},
mixins: [autoCompleteHelperMixin],
data () {
return {
columns: ['habit', 'daily', 'todo', 'reward'],
@@ -445,10 +473,19 @@ export default {
newTag: null,
editingTask: null,
creatingTask: null,
textbox: null,
activeTagIndex: -1,
};
},
computed: {
...mapState({ user: 'user.data' }),
activeTagText () {
if (this.activeTagIndex === -1) {
return this.newTag || '';
}
const tag = this.tagsSnap.tags[this.activeTagIndex];
return tag ? tag.name || '' : '';
},
tagsByType () {
const userTags = this.user.tags;
const tagsByType = {
@@ -514,6 +551,43 @@ export default {
this.tagsSnap[key].push({ id: uuid(), name: this.newTag });
this.newTag = null;
},
setActiveTag (index) {
this.activeTagIndex = index;
if (index === -1) {
const refArr = this.$refs.newTagInput;
this.textbox = Array.isArray(refArr) ? refArr[0] : refArr;
} else {
const refArr = this.$refs[`tagInput-${index}`];
if (!refArr) {
this.textbox = null;
} else {
this.textbox = Array.isArray(refArr) ? refArr[0] : refArr;
}
}
},
newTagEnterHandler (e, key) {
const ac = this._getActiveAutocomplete();
if (ac && ac.selected !== null) {
e.preventDefault();
ac.makeSelection();
} else {
if (ac) ac.cancel();
this.addTag(e, key);
}
},
selectedTagAutocomplete (newText, newCaret) {
if (this.activeTagIndex === -1) {
this.newTag = newText;
} else {
this.tagsSnap.tags[this.activeTagIndex].name = newText;
}
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
removeTag (index, key) {
const tagId = this.tagsSnap[key][index].id;
const indexInSelected = this.selectedTags.indexOf(tagId);
@@ -16,7 +16,7 @@
.badge-pin {
background-color: $white;
color: $gray-200;
color: $gray-100;
transition: none;
display: flex;
cursor: pointer;
@@ -32,8 +32,8 @@
}
.svg-icon {
width: 100%;
height: 100%;
width: 16px;
height: 16px;
}
}
@@ -0,0 +1,92 @@
<template>
<div
class="selectable-card"
:class="{ selected }"
@click="$emit('click')"
>
<div
v-if="selected"
class="checkmark-corner"
>
<div
class="svg-icon check-icon"
v-html="icons.check"
></div>
</div>
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.selectable-card {
position: relative;
background: $white;
border: 1px solid $gray-400;
border-radius: 8px;
padding: 16px;
cursor: pointer;
box-shadow: 0px 1px 2px 0px rgba(26, 24, 29, 0.08);
&:hover {
box-shadow: 0px 3px 6px 0px rgba(26, 24, 29, 0.16), 0px 3px 6px 0px rgba(26, 24, 29, 0.24);
}
&.selected {
border: 2px solid $purple-300;
padding: 15px;
box-shadow: 0px 3px 6px 0px rgba(26, 24, 29, 0.16), 0px 3px 6px 0px rgba(26, 24, 29, 0.24);
}
}
.checkmark-corner {
position: absolute;
top: 0;
left: 0;
width: 48px;
height: 48px;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
border-style: solid;
border-width: 48px 48px 0 0;
border-color: $purple-300 transparent transparent transparent;
border-radius: 6px 0 0 0;
}
.check-icon {
position: absolute;
top: 8px;
left: 8px;
width: 16px;
height: 16px;
color: $white;
}
}
</style>
<script>
import svgCheck from '@/assets/svg/check.svg?raw';
export default {
props: {
selected: {
type: Boolean,
default: false,
},
},
emits: ['click'],
data () {
return {
icons: Object.freeze({
check: svgCheck,
}),
};
},
};
</script>
@@ -398,14 +398,29 @@
:placeholder="$t('imageUrl')"
>
</div>
<div class="form-group">
<div class="form-group" style="position: relative;">
<label>{{ $t('about') }}</label>
<textarea
ref="blurbTextarea"
v-model="editingProfile.blurb"
class="form-control"
rows="5"
:placeholder="$t('displayBlurbPlaceholder')"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
></textarea>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="editingProfile.blurb"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
<!-- include ../../shared/formatting-help-->
</div>
</div>
@@ -1001,6 +1016,8 @@ import mute from '@/assets/svg/mute.svg?raw';
import shadowMute from '@/assets/svg/shadow-mute.svg?raw';
import externalLinks from '../../mixins/externalLinks';
import { userCustomStateMixin } from '../../mixins/userState';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
// @TODO: EMAILS.COMMUNITY_MANAGER_EMAIL
const COMMUNITY_MANAGER_EMAIL = 'admin@habitica.com';
@@ -1012,8 +1029,9 @@ export default {
MemberDetails,
profileStats,
toggleSwitch,
emojiAutoComplete,
},
mixins: [externalLinks, userCustomStateMixin('userLoggedIn')],
mixins: [externalLinks, userCustomStateMixin('userLoggedIn'), autoCompleteHelperMixin],
props: ['userId', 'startingPage'],
data () {
return {
@@ -1033,6 +1051,7 @@ export default {
mute,
shadowMute,
}),
textbox: null,
userIdToMessage: '',
editing: false,
editingProfile: {
@@ -1121,6 +1140,13 @@ export default {
userLoggedIn () {
this.loadUser();
},
editing (val) {
if (val) {
this.$nextTick(() => {
this.textbox = this.$refs.blurbTextarea;
});
}
},
},
mounted () {
this.loadUser();
@@ -1331,6 +1357,13 @@ export default {
this.$emit('toggled', this.isOpened);
},
selectedAutocomplete (newText, newCaret) {
this.editingProfile.blurb = newText;
this.$nextTick(() => {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
});
},
reportPlayer () {
this.$root.$emit('habitica::report-profile', {
memberId: this.user._id,
@@ -1340,7 +1373,7 @@ export default {
},
openAdminPanel () {
this.$router.push(`/admin-panel/${this.hero._id}`);
this.$router.push(`/admin/panel/${this.hero._id}`);
},
},
};
@@ -43,7 +43,7 @@
<strong>{{ $t('equipment') }}:</strong>
<span :class="{ 'positive-stat': statsComputed.gearBonus[stat] !== 0 }">
{{ statsComputed.gearBonus[stat] !== 0 ? '+' : '' }}{{
statsComputed.gearBonus[stat]
statsComputed.gearBonus[stat] + statsComputed.classBonus[stat]
}}
</span>
</li>
@@ -246,7 +246,9 @@
:class="{white: user.preferences.background}"
style="overflow:hidden"
>
<Sprite :image-name="'icon_background_' + user.preferences.background" />
<Sprite
v-if="user.preferences.background && user.preferences.background !== ''"
:image-name="'icon_background_' + user.preferences.background" />
</div>
<b-popover
v-if="label !== 'skip'
+28 -14
View File
@@ -15,46 +15,60 @@ export const autoCompleteHelperMixin = {
};
},
methods: {
_getActiveAutocomplete () {
if (this.$refs.autocomplete && this.$refs.autocomplete.searchActive) {
return this.$refs.autocomplete;
}
if (this.$refs.emojiAutocomplete && this.$refs.emojiAutocomplete.searchActive) {
return this.$refs.emojiAutocomplete;
}
return null;
},
autoCompleteMixinHandleTab (e) {
if (this.$refs.autocomplete.searchActive) {
const ac = this._getActiveAutocomplete();
if (ac) {
e.preventDefault();
if (e.shiftKey) {
this.$refs.autocomplete.selectPrevious();
ac.selectPrevious();
} else {
this.$refs.autocomplete.selectNext();
ac.selectNext();
}
}
},
autoCompleteMixinHandleEscape (e) {
if (this.$refs.autocomplete.searchActive) {
const ac = this._getActiveAutocomplete();
if (ac) {
e.preventDefault();
this.$refs.autocomplete.cancel();
ac.cancel();
}
},
autoCompleteMixinSelectNextAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
const ac = this._getActiveAutocomplete();
if (ac) {
e.preventDefault();
this.$refs.autocomplete.selectNext();
ac.selectNext();
}
},
autoCompleteMixinSelectPreviousAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
const ac = this._getActiveAutocomplete();
if (ac) {
e.preventDefault();
this.$refs.autocomplete.selectPrevious();
ac.selectPrevious();
}
},
autoCompleteMixinSelectAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
if (this.$refs.autocomplete.selected !== null) {
const ac = this._getActiveAutocomplete();
if (ac) {
if (ac.selected !== null) {
e.preventDefault();
this.$refs.autocomplete.makeSelection();
ac.makeSelection();
} else {
// no autocomplete selected, newline instead
this.$refs.autocomplete.cancel();
ac.cancel();
}
}
},
+8 -50
View File
@@ -1,56 +1,14 @@
import includes from 'lodash/includes';
export default {
methods: {
foolPet (pet, prank) {
const SPECIAL_PETS = [
'Bear-Veteran',
'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 = [
'BearCub',
'Cactus',
'Dragon',
'FlyingPig',
'Fox',
'LionCub',
'PandaCub',
'TigerCub',
'Wolf',
];
if (!pet) return `Pet-TigerCub-${prank}`;
if (SPECIAL_PETS.indexOf(pet) !== -1) {
return `Pet-Dragon-${prank}`;
foolPet (pet, substitutions) {
if (!pet || pet === 'Pet-') return substitutions.noPet;
if (substitutions[pet]) return substitutions[pet];
for (const key in substitutions) {
if (pet.startsWith(key)) {
return substitutions[key];
}
}
const species = pet.slice(0, pet.indexOf('-'));
if (includes(BASE_PETS, species)) {
return `Pet-${species}-${prank}`;
}
return `Pet-BearCub-${prank}`;
return substitutions.default;
},
},
};
@@ -153,9 +153,23 @@
:placeholder="$t('needsTextPlaceholder')"
:maxlength="MAX_MESSAGE_LENGTH"
:class="{'has-content': newMessage.trim() !== '', 'disabled': newMessageDisabled}"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keyup.ctrl.enter="sendPrivateMessage()"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</textarea>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="newMessage"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
<div
class="sub-new-message-row d-flex"
@@ -540,6 +554,7 @@ h3 {
}
.new-message-row {
position: relative;
width: 100%;
padding-left: 1.5rem;
padding-top: 1.5rem;
@@ -676,10 +691,12 @@ import PmNewMessageStarted from './pm-new-message-started.vue';
import StartNewConversationInputHeader from './start-new-conversation-input-header.vue';
import positiveIcon from '@/assets/svg/positive.svg?raw';
import NotificationMixins from '@/mixins/notifications';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
// extract to a shared path
const CONVERSATIONS_PER_PAGE = 10;
const PM_PER_PAGE = 10;
const PM_PER_PAGE = 50;
const UI_STATES = Object.freeze({
LOADING: 'LOADING',
@@ -700,13 +717,14 @@ export default defineComponent({
toggleSwitch,
userLink,
faceAvatar,
emojiAutoComplete,
},
filters: {
timeAgo (value) {
return moment(new Date(value)).fromNow();
},
},
mixins: [styleHelper, NotificationMixins],
mixins: [styleHelper, NotificationMixins, autoCompleteHelperMixin],
beforeRouteEnter (to, from, next) {
next(vm => {
const data = vm.$store.state.privateMessageOptions;
@@ -751,6 +769,7 @@ export default defineComponent({
/** @type {Record<string, PrivateMessages.PrivateMessageEntry[]>} */
messagesByConversation: {}, // cache {uuid: []}
textbox: null,
newMessage: '',
messages: [],
messagesLoading: false,
@@ -963,6 +982,15 @@ export default defineComponent({
}
},
},
watch: {
shouldShowInputPanel (val) {
if (val) {
this.$nextTick(() => {
this.textbox = this.$refs.textarea;
});
}
},
},
async mounted () {
this.$store.dispatch('common:setTitle', {
section: this.$t('messages'),
@@ -1224,6 +1252,13 @@ export default defineComponent({
triggerStartNewConversationState () {
this.showStartNewConversationInput = true;
},
selectedAutocomplete (newText, newCaret) {
this.newMessage = newText;
this.$nextTick(() => {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
});
},
async startConversationByUsername (targetUserName) {
// check if the target user exists in current conversations, select that conversation
/** @type {PrivateMessages.ConversationSummaryMessageEntry} */
@@ -67,7 +67,7 @@
<div
v-once
class="feedback"
class="feedback mt-3"
v-html="$t('feedback')"
>
</div>
+37 -24
View File
@@ -268,7 +268,6 @@ export default {
this.$store.dispatch('user:fetch'),
this.$store.dispatch('tasks:fetchUserTasks'),
]).then(() => {
this.$store.state.isUserLoaded = true;
let analyticsConsent = localStorage.getItem('analyticsConsent');
if (analyticsConsent !== null) {
analyticsConsent = analyticsConsent === 'true';
@@ -276,31 +275,11 @@ export default {
this.$store.dispatch('user:set', { 'preferences.analyticsConsent': analyticsConsent });
}
}
if (window && window['habitica-i18n']) {
if (this.user.preferences.language === window['habitica-i18n'].language.code) {
return null;
}
}
if (window && window['habitica-i18n']) {
if (this.user.preferences.language === window['habitica-i18n'].language.code) {
return null;
}
}
Analytics.updateUser();
return axios.get(
'/api/v4/i18n/browser-script',
{
language: this.user.preferences.language,
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
Expires: '0',
},
},
);
return this.loadAllTranslations();
}).then(() => {
const i18nData = window && window['habitica-i18n'];
this.$loadLocale(i18nData);
this.$store.state.isUserLoaded = true;
this.hideLoadingScreen();
// Adjust the timezone offset
@@ -316,6 +295,10 @@ export default {
appState = JSON.parse(appState);
if (appState.paymentCompleted) {
removeLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE);
if (appState.paymentType === 'groupPlan') {
this.$store.state.upgradingGroup = {};
this.$store.dispatch('guilds:getGroupPlans', true);
}
this.$root.$emit('habitica:payment-success', appState);
}
}
@@ -380,6 +363,36 @@ export default {
hideLoadingScreen () {
this.loading = false;
},
async loadContentTranslations () {
const contentTranslations = await axios.get(
'/api/v4/i18n/content',
{
language: this.user.preferences.language,
},
);
const i18nData = window && window['habitica-i18n'];
i18nData.strings = { ...i18nData.strings, ...contentTranslations.data };
this.$loadLocale(i18nData);
},
async loadAllTranslations () {
if (window && window['habitica-i18n']) {
if (this.user.preferences.language === window['habitica-i18n'].language.code) {
return this.loadContentTranslations();
}
}
await axios.get(
'/api/v4/i18n/core',
{
language: this.user.preferences.language,
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
Expires: '0',
},
},
);
return this.loadContentTranslations();
},
},
};
</script>
+14
View File
@@ -85,6 +85,13 @@ const router = new VueRouter({
props: true,
},
{ name: 'profile', path: '/user/profile' },
{
name: 'avatar',
path: '/avatar',
children: [
{ name: 'backgrounds', path: 'backgrounds' },
],
},
{ name: 'stats', path: '/user/stats' },
{ name: 'achievements', path: '/user/achievements' },
{
@@ -410,6 +417,13 @@ router.beforeEach(async (to, from, next) => {
router.app.$root.$emit('bv::hide::modal', 'profile');
}
if (to.name === 'backgrounds') {
store.state.avatarEditorOptions.editingUser = true;
store.state.avatarEditorOptions.startingPage = 'backgrounds';
router.app.$root.$emit('bv::show::modal', 'avatar-modal');
return null;
}
return next();
});
+1 -1
View File
@@ -3,7 +3,7 @@ import Vue from 'vue';
import * as Analytics from '@/libs/analytics';
export async function getChat (store, payload) {
const response = await axios.get(`/api/v4/groups/${payload.groupId}/chat`);
const response = await axios.get(`/api/v4/groups/${payload.groupId}/chat?limit=400`);
return response.data.data;
}
+1 -1
View File
@@ -122,7 +122,7 @@ export default defineConfig({
},
rollupOptions: {
output: {
experimentalMinChunkSize: 1000
experimentalMinChunkSize: 20000
}
}
},
+1 -1
View File
@@ -5,7 +5,7 @@
"keepIt": "Запазване",
"removeIt": "Премахване",
"brokenChallenge": "Повредена връзка на предизвикателство: тази задача е била част от предизвикателство, но то (или групата) е било изтрито. Какво бихте искали да направите с останалите задачи?",
"challengeCompleted": "Това предизвикателство е приключило и победителят е <span class=\"badge\"><%- user %></span>! Какво искате да направите с останалите задачи?",
"challengeCompleted": "Това предизвикателство е приключило и победителят е <span class=\"badge\"><%= user %></span>! Какво искате да направите с останалите задачи?",
"unsubChallenge": "Повредена връзка на предизвикателство: тази задача е била част от предизвикателство, но Вие сте се отписали от него. Какво искате да направите с останалите задачи?",
"challenges": "Предизвикателства",
"endDate": "Крайна дата",
+2 -2
View File
@@ -182,7 +182,7 @@
"questEggVelociraptorText": "Велоцираптор",
"questEggVelociraptorMountText": "Велоцираптор",
"questEggVelociraptorAdjective": "умен",
"eggNotes": "Намерете излюпваща отвара, която да излеете върху това яйце и от него ще се излюпи <%= eggAdjective(locale) %> <%= eggText(locale) %>.",
"eggNotes": "Намерете излюпваща отвара, която да излеете върху това яйце и от него ще се излюпи <%= eggAdjective %> <%= eggText %>.",
"hatchingPotionBase": "Нормален цвят",
"hatchingPotionWhite": "Бял цвят",
"hatchingPotionDesert": "Пустинен цвят",
@@ -211,7 +211,7 @@
"hatchingPotionGlow": "Светещо в тъмното",
"hatchingPotionFrost": "Скреж",
"hatchingPotionIcySnow": "Леден сняг",
"hatchingPotionNotes": "Излейте това върху яйце и от него ще се излюпи любимец с(ъс) <%= potText(locale) %>.",
"hatchingPotionNotes": "Излейте това върху яйце и от него ще се излюпи любимец с(ъс) <%= potText %>.",
"foodMeat": "Месо",
"foodMeatThe": "Месото",
"foodMeatA": "Месо",
+1 -1
View File
@@ -190,7 +190,7 @@
"messages": "Съобщения",
"emptyMessagesLine1": "Нямате съобщения",
"emptyMessagesLine2": "Можете да изпратите ново съобщение на потребител, като посетите профила им и докоснете бутона \"Съобщение\".",
"userSentMessage": "<span class=\"notification-bold\"><%- user %></span> Ви изпрати съобщение",
"userSentMessage": "<span class=\"notification-bold\"><%= user %></span> Ви изпрати съобщение",
"letsgo": "Хойде!",
"selected": "Избрано",
"howManyToBuy": "Колко искате да купите?",
+14 -14
View File
@@ -24,14 +24,14 @@
"userId": "Потребителски идентификатор",
"invite": "Покана",
"leave": "Напускане",
"invitedToParty": "Получихте покана за присъединяване към групата <span class=\"notification-bold\"><%- party %></span>",
"invitedToPrivateGuild": "Получихте покана за присъединяване към частната гилдия <span class=\"notification-bold\"><%- guild %></span>",
"invitedToPublicGuild": "Получихте покана за присъединяване към гилдията <span class=\"notification-bold-blue\"><%- guild %></span>",
"invitedToParty": "Получихте покана за присъединяване към групата <span class=\"notification-bold\"><%= party %></span>",
"invitedToPrivateGuild": "Получихте покана за присъединяване към частната гилдия <span class=\"notification-bold\"><%= guild %></span>",
"invitedToPublicGuild": "Получихте покана за присъединяване към гилдията <span class=\"notification-bold-blue\"><%= guild %></span>",
"invitationAcceptedHeader": "Поканата Ви беше приета",
"invitationAcceptedBody": "<%= username %> прие поканата Ви да се присъедини към <%= groupName %>!",
"systemMessage": "Системно съобщение",
"newMsgGuild": "Има нови публикации в <span class=\"notification-bold-blue\"><%- name %></span>",
"newMsgParty": "Има нови публикации в групата Ви — <span class=\"notification-bold-blue\"><%- name %></span>",
"newMsgGuild": "Има нови публикации в <span class=\"notification-bold-blue\"><%= name %></span>",
"newMsgParty": "Има нови публикации в групата Ви — <span class=\"notification-bold-blue\"><%= name %></span>",
"chat": "Съобщения",
"sendChat": "Изпращане на съобщението",
"group": "Група",
@@ -151,14 +151,14 @@
"onlyGroupLeaderCanEditTasks": "Нямате право да управлявате задачите!",
"onlyGroupTasksCanBeAssigned": "Само групови задачи могат да бъдат зададени",
"assignedTo": "Назначена на",
"assignedToUser": "Назначена на <strong><%- userName %></strong>",
"assignedToUser": "Назначена на <strong><%= userName %></strong>",
"assignedToMembers": "Назначена на <strong><%= userCount %> членове </strong>",
"assignedToYouAndMembers": "Назначена на Вас и още <strong><%= userCount %> членове</strong>",
"youAreAssigned": "Тази задача е назначена на Вас",
"taskIsUnassigned": "Тази задача не е зададена на никого",
"confirmUnClaim": "Наистина ли искате да оставите тази задача?",
"confirmNeedsWork": "Наистина ли искате да отбележите, че тази задача се нуждае от още работа?",
"userRequestsApproval": "<strong><%- userName %></strong> иска одобрение",
"userRequestsApproval": "<strong><%= userName %></strong> иска одобрение",
"userCountRequestsApproval": "<strong><%= userCount %> членове</strong> искат одобрение",
"youAreRequestingApproval": "Вие искате одобрение",
"chatPrivilegesRevoked": "Не можете да направите това, защото привилегиите Ви в чата са Ви били отнети. За детайли или запитване за връшане на привилегии, моля пратете email на нашия Обществен Оправител на admin@habitica.com или попитайте вашия родител или настойник да им прати email. Моля, напишете и потребителското си име в писмото. Ако модератор вече ви е казал че блокирането ви към чата е временно, няма нужда да пращате email.",
@@ -168,9 +168,9 @@
"claim": "Вземане на Задача",
"removeClaim": "Отказване от задачата",
"onlyGroupLeaderCanManageSubscription": "Само водачът на групата може да управлява абонамента ѝ",
"yourTaskHasBeenApproved": "Задачата Ви <span class=\"notification-green notification-bold\"><%- taskText %></span>, беше одобрена.",
"taskNeedsWork": "<span class=\"notification-bold\"><%- managerName %></span> отбеляза, че задачата <span class=\"notification-bold\"><%- taskText %></span> се нуждае от още работа.",
"userHasRequestedTaskApproval": "<span class=\"notification-bold\"><%- user %></span> помоли следната задача да бъде одобрена: <span class=\"notification-bold\"><%- taskName %></span>",
"yourTaskHasBeenApproved": "Задачата Ви <span class=\"notification-green notification-bold\"><%= taskText %></span>, беше одобрена.",
"taskNeedsWork": "<span class=\"notification-bold\"><%= managerName %></span> отбеляза, че задачата <span class=\"notification-bold\"><%= taskText %></span> се нуждае от още работа.",
"userHasRequestedTaskApproval": "<span class=\"notification-bold\"><%= user %></span> помоли следната задача да бъде одобрена: <span class=\"notification-bold\"><%= taskName %></span>",
"approve": "Одобряване",
"approveTask": "Одобряване на задачата",
"needsWork": "Нуждае се от още работа",
@@ -183,8 +183,8 @@
"userIsClamingTask": "`<%= username %> пое:` <%= task %>",
"approvalRequested": "Заявено е одобрение",
"cantDeleteAssignedGroupTasks": "Не можете да изтриете груповите задачи, които са Ви разпределени.",
"groupPlanUpgraded": "<strong><%- groupName %></strong> премина към групов план!",
"groupPlanCreated": "Групата <strong><%- groupName %></strong> беше създадена!",
"groupPlanUpgraded": "<strong><%= groupName %></strong> премина към групов план!",
"groupPlanCreated": "Групата <strong><%= groupName %></strong> беше създадена!",
"onlyGroupLeaderCanInviteToGroupPlan": "Само водачът на групата може да кани хора в група с абонамент.",
"paymentDetails": "Подробности за разплащането",
"aboutToJoinCancelledGroupPlan": "На път сте да се присъедините към група, чийто план е прекратен. НЯМА да получите безплатен абонамент.",
@@ -325,8 +325,8 @@
"PMDisabled": "Деактивиране на лични съобщения",
"groupActivityNotificationTitle": "<%= user %> публикува в <%= group %>",
"suggestedGroup": "Предложено, защото сте нови в Habitica.",
"taskClaimed": "<%- userName %> взеха задачата <span class=\"notification-bold\"><%- taskText %></span>.",
"youHaveBeenAssignedTask": "<%- managerName %> ви присвои задачата <span class=\"notification-bold\"><%- taskText %></span>.",
"taskClaimed": "<%= userName %> взеха задачата <span class=\"notification-bold\"><%= taskText %></span>.",
"youHaveBeenAssignedTask": "<%= managerName %> ви присвои задачата <span class=\"notification-bold\"><%= taskText %></span>.",
"userWithUsernameOrUserIdNotFound": "Потребителското име или Потребителският Идентификатор не бяха намерени.",
"usernameOrUserId": "Потребителско име или Потребителски Идентификатор",
"sendGiftToWhom": "На кой бихте искали да пратите подарък?",
+2 -2
View File
@@ -82,8 +82,8 @@
"paymentMethods": "Купуване чрез",
"paymentSuccessful": "Плащането Ви беше успешно!",
"paymentYouReceived": "Получихте:",
"paymentYouSentGems": "Изпратихте на <strong><%- name %></strong>:",
"paymentYouSentSubscription": "Изпратихте на <strong><%- name %></strong> <%= months %>-месечен абонамент за Хабитика.",
"paymentYouSentGems": "Изпратихте на <strong><%= name %></strong>:",
"paymentYouSentSubscription": "Изпратихте на <strong><%= name %></strong> <%= months %>-месечен абонамент за Хабитика.",
"paymentSubBilling": "Абонаментът Ви ще бъде таксуван с <strong>$<%= amount %></strong> всеки <strong><%= months %> месеца</strong>.",
"success": "Готово!",
"classGear": "Снаряжение за класа",
+2 -2
View File
@@ -66,8 +66,8 @@
"mountNotOwned": "Не притежавате този прево.",
"feedPet": "Искате ли да дадете <%= text %> на <%= name %>?",
"raisedPet": "Вие отгледахте <%= pet %>!",
"petName": "<%= egg(locale) %> с(ъс) <%= potion(locale) %>",
"mountName": "<%= mount(locale) %> с(ъс) <%= potion(locale) %>",
"petName": "<%= egg %> с(ъс) <%= potion %>",
"mountName": "<%= mount %> с(ъс) <%= potion %>",
"keyToPets": "Ключ от зверилника за любимци",
"keyToPetsDesc": "Освобождаване на всички стандартни любимци, за да можете да ги съберете отново. (Това не засяга любимците от мисии и редките любимци.)",
"keyToMounts": "Ключ от зверилника за превози",
+9 -4
View File
@@ -104,15 +104,15 @@
"achievementSkeletonCrewModalText": "Posbíral/a jsi všechna kostnatá zvířata!",
"achievementSkeletonCrewText": "Posbíral/a všechna kostnatá zvířata.",
"achievementLegendaryBestiaryModalText": "Posbíral/a jsi všechny mytické mazlíčky!",
"achievementLegendaryBestiaryText": "Posbíral/a jsi všechny základní barvy mytických mazlíčků: draka, létajícího prasete, gryfona, mořského hada a jednorožce!",
"achievementLegendaryBestiaryText": "Posbíral/a všechny barvy mytických mazlíčků: drak, létající prase, gryfon, mořský hady a jednorožec!",
"achievementLegendaryBestiary": "Legendární bestiář",
"achievementSeasonalSpecialist": "Sezónní specialista",
"achievementVioletsAreBlueText": "Získal/a všechny cukrově modré mazlíčky.",
"achievementVioletsAreBlue": "Fialky jsou Modré",
"achievementVioletsAreBlue": "Fialky jsou modré",
"achievementVioletsAreBlueModalText": "Posbíral/a jsi všechny mazlíčky z Modré Cukrové Vaty!",
"achievementSeasonalSpecialistModalText": "Dokončl/a jsi všechny sezónní úkoly!",
"achievementDomesticatedModalText": "Sesbíral/a jsi všechna domácí zvířata!",
"achievementSeasonalSpecialistText": "Dokončil/a jsi všechny Jarní a Zimní sezónní úkoly: Honba za vajíčky, Pastičkář Santa, a najdi Cuba!",
"achievementSeasonalSpecialistText": "Splnil/a všechny jarní a zimní sezonní úkoly: Lov Vajec, Uvězněný Santa, a Najdi Mládě!",
"achievementWildBlueYonderText": "Ochočil/a všechny zvířata z Modré Cukrové Vaty.",
"achievementWildBlueYonderModalText": "Ochočil/a jsi všechny mazlíčky z Modré Cukrové Vaty!",
"achievementDomesticatedText": "Vylíhl/a všechna standardní zbarvení domácích zvířat: Fretka, morče, kohout, létající prasátko, krysa, králík, kůň a kráva!",
@@ -157,5 +157,10 @@
"achievementBonelessBossModalText": "Získal/a jsi všechny bezobratlé mazlíčky!",
"achievementDuneBuddy": "Kámoš z dun",
"achievementDuneBuddyText": "Vylíhl/a jsi všechny, v poušti se vyskytující, mazlíčky: pásovce, kaktus, lišku, žábu, hada a pavouka!",
"achievementDuneBuddyModalText": "Sesbíral jsi všechna zvířata žijící v poušti!"
"achievementDuneBuddyModalText": "Sesbíral jsi všechna zvířata žijící v poušti!",
"achievementRodentRulerText": "Vylíhly se všechny standardní barvy hlodavců: morčata, krysy a veverky!",
"achievementCatsModalText": "Nasbíral jsi všechny kočičí mazlíčky!",
"achievementRoughRiderModalText": "Nasbíral jsi všechny základní barvy nepohodlných mazlíčků a mountů!",
"achievementRodentRulerModalText": "Nasbíral jsi všechny hlodavce!",
"achievementCatsText": "Vylíhly se všechny standardní barvy kočičích mazlíčků: gepard, lev, šavlozubý tygr a tygr!"
}
+4 -1
View File
@@ -735,5 +735,8 @@
"backgroundMaskMakersWorkshopText": "Maskářova dílna",
"backgroundMaskMakersWorkshopNotes": "Vyzkoušej novou tvář v maskářově dílně.",
"backgroundCemeteryGateText": "Hřbitovní brána",
"backgroundCemeteryGateNotes": "Straš u hřbitovní brány."
"backgroundCemeteryGateNotes": "Straš u hřbitovní brány.",
"backgroundAutumnBridgeText": "Podzimní most",
"backgroundAutumnBridgeNotes": "Obdivuj krásu podzimního mostu.",
"backgroundInsideACrystalText": "Uvnitř krystalu."
}
+1 -1
View File
@@ -5,7 +5,7 @@
"keepIt": "Ponechat",
"removeIt": "Odstranit",
"brokenChallenge": "Nefunkční odkaz na výzvu: tento úkol byl součástí výzvy, ale ta (nebo skupina, která ji vytvořila) byla odstraněna. Co chceš dělat s osiřelými úkoly?",
"challengeCompleted": "Výzva byla ukončena a vítězem se stal <span class=\"badge\"><%- user %></span>! Co chceš dělat s osiřelými úkoly?",
"challengeCompleted": "Výzva byla ukončena a vítězem se stal <span class=\"badge\"><%= user %></span>! Co chceš dělat s osiřelými úkoly?",
"unsubChallenge": "Nefunkční odkaz na výzvu: tento úkol byl součástí výzvy, ze které jsi se odhlásil/a. Co chceš dělat s osiřelými úkoly?",
"challenges": "Výzvy",
"endDate": "Končí",
+2 -2
View File
@@ -182,7 +182,7 @@
"questEggVelociraptorText": "Velociraptor",
"questEggVelociraptorMountText": "Velociraptor",
"questEggVelociraptorAdjective": "chytrý",
"eggNotes": "Najdi líhnoucí lektvar, nalij ho na vejce a to se vylíhne v <%= eggAdjective(locale) %> <%= eggText(locale) %>.",
"eggNotes": "Najdi líhnoucí lektvar, nalij ho na vejce a to se vylíhne v <%= eggAdjective %> <%= eggText %>.",
"hatchingPotionBase": "Základní",
"hatchingPotionWhite": "Bílý",
"hatchingPotionDesert": "Pouštní",
@@ -211,7 +211,7 @@
"hatchingPotionGlow": "Ve tmě svítící",
"hatchingPotionFrost": "Zmrzlý",
"hatchingPotionIcySnow": "Ledově Sněhový",
"hatchingPotionNotes": "Nalij ho na vejce a vylíhne se ti <%= potText(locale) %> mazlíček.",
"hatchingPotionNotes": "Nalij ho na vejce a vylíhne se ti <%= potText %> mazlíček.",
"foodMeat": "Maso",
"foodMeatThe": "Maso",
"foodMeatA": "Maso",
+13 -7
View File
@@ -3,9 +3,9 @@
"iosFaqStillNeedHelp": "Jestli máš otázku, která není na tomto seznamu nebo na [Wiki FAQ](http://habitica.fandom.com/wiki/FAQ), použij formulář Ask a Question v sekci Nápověda na horní liště rozhraní. Jsme rádi když můžeme pomoct.",
"androidFaqStillNeedHelp": "If you have a question that isn't on this list or on the [Wiki FAQ](http://habitica.fandom.com/wiki/FAQ), come ask in the Tavern chat under Menu > Tavern! We're happy to help.",
"webFaqStillNeedHelp": "Pokud máš otázku, která není na tomto seznamu nebo na [Wiki FAQ](https://habitica.fandom.com/wiki/FAQ), přijď se zeptat do [Cechu „Habitica Help‟](https://habitica.com/groups/guild/5481ccf3-5d2d-48a9-a871-70a7380cee5a)! Rádi ti pomůžeme.",
"webFaqAnswer25": "Habitica používá tři různé typy úkolů, které se přizpůsobují tvým potřebám: Návyky, Denní úkoly a Úkolníček.\n\nNávyky mohou být pozitivní či negativní a vyjadřují něco, co můžeš chtít zaznamenat několikrát denně, nebo dle nestálého rozvrhu. Pozitivní návyky ti získají odměny, jako zlaťáky a zkušenosti , zatímco negativní návyky způsobí, že ztratíš body zdraví. \n\nDenní úkoly jsou úkoly, které chceš splnit pravidelněji, například jednou denně, třikrát týdně, nebo čtyřikrát za měsíc. Nesplněné denní úkoly tě stojí body zdraví, ale zároveň čím jsou náročnější, tím lepší odměnu nabízejí.\n\nÚkolníček zahrnuje jednorázové úkoly, ze kterých po jejich splnění získáš odměny. Úkoly v úkolníčku mohou mít zadané datum dokončení, ale pokud ho nestihneš, neztratíš žádné zkušenostní body.\n\nVyber si takový typ úkolu, který ti nejlépe pomůže dosáhnout tvých cílů!",
"webFaqAnswer25": "Habitica používá tři různé typy úkolů, které se přizpůsobují tvým potřebám: Návyky, Denní úkoly a Úkolníček.\n\nNávyky mohou být pozitivní či negativní a vyjadřují něco, co můžeš chtít zaznamenat několikrát denně, nebo dle nestálého rozvrhu. Pozitivní návyky ti získají odměny, jako zlaťáky a zkušenosti , zatímco negativní návyky způsobí, že ztratíš body zdraví.\n\nDenní úkoly jsou úkoly, které chceš splnit pravidelněji, například jednou denně, třikrát týdně, nebo čtyřikrát za měsíc. Nesplněné denní úkoly tě stojí body zdraví, ale zároveň čím jsou náročnější, tím lepší odměnu nabízejí.\n\nÚkolníček zahrnuje jednorázové úkoly, ze kterých po jejich splnění získáš odměny. Úkoly v úkolníčku mohou mít zadané datum dokončení, ale pokud ho nestihneš, neztratíš žádné zkušenostní body.\n\nVyber si takový typ úkolu, který ti nejlépe pomůže dosáhnout tvých cílů!",
"webFaqAnswer26": "Pozitivní návyky (návyky, které chceš udržovat; měly by mít tlačítko plus)\n\n * Sněz vitamíny\n * Vyčisti si zuby\n * Hodina učení se\n\nNegativní návyky (návyky které chceš omezit nebo se jim zcela vyhnout; měly by mít tlačítko mínus)\n\n * Kouření\n * Bezmyšlenkovité scrollování\n * Kousání si nehtů\n\nOboustranné návyky (Návyky které mají jak pozitivní, tak negativní možnost; měly by mít tlačítko plus i mínus)\n\n * Pít vodu vs. Pít limonádu\n * Učit se vs. prokrastinovat\n\nNávrhy denních úkolů (úkoly, které chceš plnit pravidelně)\n * Umýt nádobí\n * Zalít kytky\n * 30 minut nějaké fyzické aktivity\n\nNávrhy úkolů do Úkolníčku (úkoly co chceš splnit jen jednou)\n\n * Objednat se k doktorovi\n * Zorganizovat obsah skříně\n * Dopsat esej",
"webFaqAnswer35": "Jakmile jsi nakrmil svého mazlíčka natolik, že vyrostl v dospělé zvíře, budeš ten typ mazlíčka muset nechat vylíhnout znovu, pokud ho chceš mít nadále ve stáji.\n\nPokud chceš vidět zvířata na mobilních aplikacích:\n\n * Na menu vyber “Mazlíčci & zvířata” (Pets & Mounts) a klikni na popisek Zvířata (Mounts)\n\nPokud chceš vidět zvířata na webových stránkách:\n\n * Z inventáře na menu vyber “Stáj” and sjeď dolů, k sekci Stáj",
"webFaqAnswer35": "Jakmile jsi nakrmil svého mazlíčka natolik, že vyrostl v dospělé zvíře, budeš ten typ mazlíčka muset nechat vylíhnout znovu, pokud ho chceš mít nadále ve stáji.\n\nPokud chceš vidět zvířata na mobilních aplikacích:\n\n * Na menu vyber “Mazlíčci & zvířata” (Pets & Mounts) a klikni na popisek Zvířata (Mounts)\n\nPokud chceš vidět zvířata na webových stránkách:\n\n * Z inventáře na menu vyber “Domácí zvířata a mounti” and sjeď dolů, k sekci Stáj",
"commonQuestions": "Časté otázky",
"faqQuestion25": "Jaké různé úkoly existují?",
"faqQuestion26": "Jaké úkoly mohu například vytvořit?",
@@ -13,19 +13,25 @@
"faqQuestion29": "Jak získám zpět Zdraví?",
"webFaqAnswer29": "Můžeš získat 15 bodů zdraví zakoupením Lektvaru zdraví ze sloupce Odměny za 25 zlaťáků. Navíc, pokud postoupíš do další úrovně, tak se ti všechno zdraví automaticky obnoví!",
"faqQuestion30": "Co se stane, když mi dojde zdraví?",
"webFaqAnswer30": "Pokud tvé zdraví dosáhne hodnoty nula, přijdeš o jednu úroveň, všechny zlaťáky a jeden kousek vybavení, který se dá znovu zakoupit.",
"webFaqAnswer30": "Pokud tvé zdraví dosáhne hodnoty nula, přijdeš o jednu úroveň, všechny zlaťáky a jeden kousek vybavení, který se dá znovu zakoupit. Můžete se znovu postavit plněním úkolů a opětovným zvyšováním úrovně.",
"faqQuestion31": "Proč jsem ztratil body, když jsem řešil úkol, který nebyl negativní?",
"webFaqAnswer31": "Když doděláš úkol a ztratíš zdraví i když bys správně neměl, narazil jsi na zpoždění, během kterého server synchronizoval změny na jiných platformách. Například, pokud použiješ zlaťáky, manu nebo ztratíš zkušenosti na aplikaci na mobilu a pak dokončíš akci na webově stránce, server jednoduše potvrzuje, že se všechno synchronizovalo.",
"faqQuestion32": "Kdy si mohu vybrat třídu?",
"webFaqAnswer32": "V Habitice existují čtyři třídy: Válečník, Mág, Zloděj a Léčitel. Všichni hráči začínají jako válečníci, dokud nedosáhnou 10. úrovně. Jakmile dosáhneš 10. úrovně, dostaneš na výběr, jestli chceš zůstat válečníkem, nebo si vybrat jinou třídu. \n\nKaždá třída využívá rozdílné vybavení a schopnosti. Pokud si nechceš vybírat třídu, můžeš vybrat „Zatím nic.“ Pokud sis zatím nevybral, můžeš později třídní systém vždycky znovu aktivovat v nastavení.",
"faqQuestion32": "Jak si mohu vybrat kurz?",
"webFaqAnswer32": "Všichni hráči začínají jako válečníci, dokud nedosáhnou 10. úrovně. Jakmile dosáhneš 10. úrovně, dostaneš na výběr, jestli chceš zůstat válečníkem, nebo si vybrat jinou třídu. \n\nKaždá třída využívá rozdílné vybavení a schopnosti. Pokud si nechceš vybírat třídu, můžeš vybrat „Zatím nic.“ Pokud sis zatím nevybral, můžeš později třídní systém vždycky znovu aktivovat v nastavení.\n\nPokud chcete změnit svou třídu po dosažení úrovně 10, můžete tak učinit pomocí Koule znovuzrození. Koule znovuzrození je k dispozici na trhu za 6 drahokamů na úrovni 50 nebo zdarma na úrovni 100.\n\nAlternativně můžete změnit třídu kdykoli v nastavení za 3 drahokamy. Tím se vaše úroveň nevynuluje jako v případě Koule znovuzrození, ale budete moci přerozdělit body dovedností, které jste nashromáždili při postupu na vyšší úroveň, tak aby odpovídaly vaší nové třídě.",
"faqQuestion33": "Co je to za modrou čáru s popisem Mana, která se objeví po dosažení 10. úrovně?",
"webFaqAnswer33": "Poté, co odemkneš třídní systém, tak odemkneš i schopnosti, jež ke svému použití vyžadují manu. Mana je učena tvou INT (inteligencí) a dá se měnit pomocí schopností a vybavení.",
"faqQuestion34": "Jaký typ jídla má rád můj mazlíček?",
"webFaqAnswer34": "Mazlíčci mají rádi jídla, která jim jdou barevně k srsti. Základní mazlíčci jsou výjimka, ale všichni základní mazlíčci mají rádi stejný předmět. Dole vidíš jídla, která mají specifičtí mazlíčci rádi:\n\n * Základní mazlíčci mají rádi maso\n * Bílí mazlíčci mají rádi mléko\n * Pouštní mazlíčci mají rádi brambory\n * Červení mazlíčci mají rádi jahody\n * Stínoví mazlíčci mají rádi čokoládu\n * Kostnatí mazlíčci mají rádi ryby\n * Zombie mazlíčci mají rádi hnijící maso\n * Cukrově růžoví mazlíčci mají rádi růžovou cukrovou vatu\n * Cukrově modří mazlíčci mají rádi modrou cukrovou vatu\n * Zlatí mazlíčci mají rádi med",
"faqQuestion35": "Nakrmil jsem svého mazlíčka a on zmizel! Co se stalo?",
"faqQuestion36": "Jak mohu změnit vzhled své postavy?",
"webFaqAnswer36": "Existuje nespočet způsobů jak změnit vzhled své postavy na Habitice! Můžeš změnit jeho tělesnou stavbu, barvu a styl vlasů, barvu kůže nebo třeba přidat brýle a pohybové pomůcky tím, že na menu vybereš Upravit postavu.\n\nAbys upravil postavu na mobilní aplikaci:\n * v menu vyber “Customize Avatar”\n\nAbys upravil postavu na webových stránkách:\n * Z uživatelského menu v navigaci, v pravém rohu, vyber \"Upravit postavu\"",
"webFaqAnswer36": "Existuje nespočet způsobů jak změnit vzhled své postavy na Habitice! Můžeš změnit jeho tělesnou stavbu, barvu a styl vlasů, barvu kůže nebo třeba přidat brýle a pohybové pomůcky tím, že na menu vybereš Přizpůsobit postavu.\n\nAbys upravil postavu na mobilní aplikaci:\n * v menu vyber “Customize Avatar”\n\nAbys upravil postavu na webových stránkách:\n * Z uživatelského menu v navigaci, v pravém rohu, vyber \"Přizpůsobit postavu\"",
"faqQuestion27": "Proč úkoly mění barvy?",
"webFaqAnswer27": "Barva úkolu je vizuální ukázkou hodnoty úkolu. Všechny úkoly začínají neutrálně žlutě, modrá je lepší a červená horší. Zde uvidíš jak typ úkolu určuje hodnotu úkolu:\n\nNávyky zmodrají nebo zčervenají podle toho, jestli klikneš na tlačítko plus nebo mínus. Pokud je nebudeš plnit, tak pozitivní a negativní úkoly oslabíš až na žlutou. Dvojité návyky mění barvy pouze na základě tvých zadání.\n\nDenní úkoly mění barvu podle toho, jak často jsou plněny a když se plní, stávají se modřejšími, nebo pokud jsou zanedbány, zčervenají.\n\nČím déle jsou úkoly v úkolníčku nesplněné, tím červenějšími se stávají.\n\nČím červenější úkol, tím víc zlaťáků a zkušeností získáš za jeho splnění, takže se vrhni i na ty nejdrsnější úkoly!",
"faqQuestion28": "Pokud potřebuji pauzu, mohu si pozastavit denní úkoly?"
"faqQuestion28": "Pokud potřebuji pauzu, mohu si pozastavit denní úkoly?",
"faqQuestion37": "Proč se mé vybavení neukazuje na mé postavě?",
"webFaqAnswer37": "Zkontrolujte, zda je zapnutá možnost Kostým. Pokud má váš avatar na sobě kostým, zobrazí se místo vaší bojové výstroje tato sada vybavení.\n\nZapnutí kostýmu v mobilních aplikacích:\n * V nabídce vyberte „Vybavení“ a najděte přepínač Kostým.\n\nZapnutí kostýmu na webových stránkách:\n * V inventáři vyberte „Vybavení“ a najděte přepínač Kostým v záložce Kostým v zásuvce Vybavení",
"faqQuestion38": "Proč nemohu zakoupit určité položky?",
"webFaqAnswer38": "Noví hráči Habitica mohou zakoupit pouze základní vybavení třídy válečník. Hráči musí nakupovat vybavení v pořadí, aby odemkli další kus.\n\nMnoho kusů vybavení je specifických pro danou třídu, což znamená, že hráč může zakoupit pouze vybavení patřící k jeho aktuální třídě.",
"faqQuestion39": "Kde mohu získat další vybavení?",
"faqQuestion40": "Co jsou gemy a jak je dostanu?"
}
+1 -1
View File
@@ -190,7 +190,7 @@
"messages": "Zprávy",
"emptyMessagesLine1": "Nemáš žádné zprávy",
"emptyMessagesLine2": "Novou zprávu uživateli/Česku můžeš poslat tak, že navštívíš jeho/její profil a klikneš na tlačítko “Zprávy”.",
"userSentMessage": "<span class=\"notification-bold\"><%- user %></span> ti poslal/a zprávu",
"userSentMessage": "<span class=\"notification-bold\"><%= user %></span> ti poslal/a zprávu",
"letsgo": "Pojďmě!",
"selected": "Vybrané",
"howManyToBuy": "Kolik by jsi chtěl koupit?",
+15 -15
View File
@@ -24,14 +24,14 @@
"userId": "Uživatelské ID",
"invite": "Pozvat",
"leave": "Odejít",
"invitedToParty": "Byl jsi pozván do družiny <span class=\"notification-bold\"><%- party %></span>",
"invitedToPrivateGuild": "Byl jsi pozván do soukromého cechu <span class=\"notification-bold\"><%- guild %></span>",
"invitedToPublicGuild": "Byl jsi pozván do cechu <span class=\"notification-bold-blue\"><%- guild %></span>",
"invitedToParty": "Byl jsi pozván do družiny <span class=\"notification-bold\"><%= party %></span>",
"invitedToPrivateGuild": "Byl jsi pozván do soukromého cechu <span class=\"notification-bold\"><%= guild %></span>",
"invitedToPublicGuild": "Byl jsi pozván do cechu <span class=\"notification-bold-blue\"><%= guild %></span>",
"invitationAcceptedHeader": "Tvá pozvánka byla přijata",
"invitationAcceptedBody": "<%= username %> přijal tvoji pozvánku do <%= groupName %>!",
"systemMessage": "Systémová zpráva",
"newMsgGuild": "<span class=\"notification-bold-blue\"><%- name %></span> má nový příspěvek",
"newMsgParty": "Tvá družina, <span class=\"notification-bold-blue\"><%- name %></span>, má nový příspěvek",
"newMsgGuild": "<span class=\"notification-bold-blue\"><%= name %></span> má nový příspěvek",
"newMsgParty": "Tvá družina, <span class=\"notification-bold-blue\"><%= name %></span>, má nový příspěvek",
"chat": "Chat",
"sendChat": "Poslat zprávu",
"group": "Skupina",
@@ -151,14 +151,14 @@
"onlyGroupLeaderCanEditTasks": "Not authorized to manage tasks!",
"onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned",
"assignedTo": "Přiřadit k",
"assignedToUser": "Přiřazeno <strong><%- userName %></strong>",
"assignedToUser": "Přiřazeno <strong><%= userName %></strong>",
"assignedToMembers": "Přiřazeno <strong><%= userCount %> members</strong>",
"assignedToYouAndMembers": "Přiřazeno vám a <strong><%= userCount %> members</strong>",
"youAreAssigned": "Jsi přiřazen/a k tomuto úkolu",
"taskIsUnassigned": "This task is unassigned",
"confirmUnClaim": "Are you sure you want to unclaim this task?",
"confirmNeedsWork": "Are you sure you want to mark this task as needing work?",
"userRequestsApproval": "<strong><%- userName %></strong> požaduje schválení",
"userRequestsApproval": "<strong><%= userName %></strong> požaduje schválení",
"userCountRequestsApproval": "<strong><%= userCount %> members</strong> požadují schválení",
"youAreRequestingApproval": "You are requesting approval",
"chatPrivilegesRevoked": "Toto nelze provést, protože vaše oprávnění k chatu byla odstraněna. Chcete-li získat další informace nebo se zeptat, zda lze vaše oprávnění vrátit, pošlete e-mail našemu komunitnímu manažerovi na adrese admin@habitica.com nebo požádejte svého rodiče nebo zákonného zástupce o zaslání e-mailu. Do e-mailu uveďte prosím své @uživatelskéjméno. Pokud vám moderátor již řekl, že váš zákaz chatu je dočasný, nemusíte posílat e-maily.",
@@ -168,9 +168,9 @@
"claim": "Nárokovat úkol",
"removeClaim": "Remove Claim",
"onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription",
"yourTaskHasBeenApproved": "Váš úkol <span class=\"notification-green notification-bold\"><%- taskText %></span> byl schválený.",
"taskNeedsWork": "<span class=\"notification-bold\"><%- managerName %></span> marked <span class=\"notification-bold\"><%- taskText %></span> as needing additional work.",
"userHasRequestedTaskApproval": "<span class=\"notification-bold\"><%- user %></span> requests approval for <span class=\"notification-bold\"><%- taskName %></span>",
"yourTaskHasBeenApproved": "Váš úkol <span class=\"notification-green notification-bold\"><%= taskText %></span> byl schválený.",
"taskNeedsWork": "<span class=\"notification-bold\"><%= managerName %></span> marked <span class=\"notification-bold\"><%= taskText %></span> as needing additional work.",
"userHasRequestedTaskApproval": "<span class=\"notification-bold\"><%= user %></span> requests approval for <span class=\"notification-bold\"><%= taskName %></span>",
"approve": "Approve",
"approveTask": "Approve Task",
"needsWork": "Needs Work",
@@ -183,8 +183,8 @@
"userIsClamingTask": "`<%= username %> has claimed:` <%= task %>",
"approvalRequested": "Approval Requested",
"cantDeleteAssignedGroupTasks": "Can't delete group tasks that are assigned to you.",
"groupPlanUpgraded": "<strong><%- groupName %></strong> was upgraded to a Group Plan!",
"groupPlanCreated": "<strong><%- groupName %></strong> was created!",
"groupPlanUpgraded": "<strong><%= groupName %></strong> was upgraded to a Group Plan!",
"groupPlanCreated": "<strong><%= groupName %></strong> was created!",
"onlyGroupLeaderCanInviteToGroupPlan": "Only the group leader can invite users to a group with a subscription.",
"paymentDetails": "Payment Details",
"aboutToJoinCancelledGroupPlan": "You are about to join a group with a canceled plan. You will NOT receive a free subscription.",
@@ -321,8 +321,8 @@
"allAssignedCompletion": "All - Completes when all assigned users finish",
"groupActivityNotificationTitle": "<%= user %> publikoval v <%= group %>",
"suggestedGroup": "Navrženo, protože jste v Habitica nový/á.",
"taskClaimed": "<%- userName %> nárokoval úkol <span class=\"notification-bold\"><%- taskText %></span>.",
"youHaveBeenAssignedTask": "<%- managerName %> vám přidělil úkol <span class=\"notification-bold\"><%- taskText %></span>.",
"taskClaimed": "<%= userName %> nárokoval úkol <span class=\"notification-bold\"><%= taskText %></span>.",
"youHaveBeenAssignedTask": "<%= managerName %> vám přidělil úkol <span class=\"notification-bold\"><%= taskText %></span>.",
"pmReported": "Děkujeme za nahlášení této zprávy.",
"newPartyPlaceholder": "Zadej jméno tvé družiny.",
"userWithUsernameOrUserIdNotFound": "Uživatelské jméno nebo uživatelské ID nebylo nalezeno.",
@@ -336,7 +336,7 @@
"PMDisabled": "Zakaž soukromé zprávy",
"unassigned": "Nepřiřazeno",
"claimRewards": "Vyzvedni si odměnu",
"assignedDateAndUser": "Přiřazeno uživatelem/kou <strong>@<%- username %></strong> dne <strong><%= date %></strong>",
"assignedDateAndUser": "Přiřazeno uživatelem/kou <strong>@<%= username %></strong> dne <strong><%= date %></strong>",
"assignedDateOnly": "Přiřazeno k <strong><%= date %></strong>",
"managerNotes": "Poznámky manažera",
"thisTaskApproved": "Tento úkol byl schválen",
+2 -2
View File
@@ -82,8 +82,8 @@
"paymentMethods": "Platební metody",
"paymentSuccessful": "Tvá platba proběhla úspěšně!",
"paymentYouReceived": "Obdržel jsi:",
"paymentYouSentGems": "Poslal/a jsi <strong><%- name %></strong>:",
"paymentYouSentSubscription": "Poslal/a jsi <strong><%- name %></strong> předplatné na <%= months %>-měsíce/ů v Habitica.",
"paymentYouSentGems": "Poslal/a jsi <strong><%= name %></strong>:",
"paymentYouSentSubscription": "Poslal/a jsi <strong><%= name %></strong> předplatné na <%= months %>-měsíce/ů v Habitica.",
"paymentSubBilling": "Tvoje předplatné ve výši <strong>$<%= amount %></strong> bude účtovano každé/ých <strong><%= months %> měsíce/ů </strong>.",
"success": "Úspěch!",
"classGear": "Vybavení pro tvé povolání",
+2 -2
View File
@@ -66,8 +66,8 @@
"mountNotOwned": "Nevlastníš toto jezdecké zvíře.",
"feedPet": "Dát <%= text %> svému <%= name %>?",
"raisedPet": "Vychoval jsi svého <%= pet %>!",
"petName": "<%= potion(locale) %> <%= egg(locale) %>",
"mountName": "<%= potion(locale) %> <%= mount(locale) %>",
"petName": "<%= potion %> <%= egg %>",
"mountName": "<%= potion %> <%= mount %>",
"keyToPets": "Klíč ke Kotcům Mazlíčků",
"keyToPetsDesc": "Propusť všechny své běžné mazlíčky abys je mohl sbírat znovu. (Ti vzácní a z výprav tím nebudou ovlivněni.)",
"keyToMounts": "Klíč ke Kotcům Zvířat",
+1 -1
View File
@@ -8,7 +8,7 @@
"rebirthOrb": "Použil Kouli Znovozrození, aby začal znova, po dosáhnutí úrovně <%= level %>.",
"rebirthOrb100": "Použil Kouli znovuzrození, aby začal odznovu po dosažení úrovně 100 nebo vyšší.",
"rebirthOrbNoLevel": "Použil Kouli Znovozrození, aby začal znova.",
"rebirthPop": "Obnoví tvou postavu a vrátí jí na 1. úroveň s povoláním Válečníka, zatímco ti zůstanou všechny úspěchy, celá sbírka a vybavení. Tvoje úkoly zůstanou i s historií, ale vrátí se na žlutou barvu. Tvé řady úspěchů se resetují, kromě úkolů patřících do aktivních výzev či do Skupiny. Tvé zlato, zkušenosti, mana a efekty všech schopností budou odstraněny. Toto vše nastane s okamžitou platností. Pro více informací se podívej na wiki stránku: <a href='https://habitica.fandom.com/wiki/Orb_of_Rebirth' target='_blank'>Orb znovuzrození</a>.",
"rebirthPop": "Obnoví tvou postavu a vrátí jí na 1. úroveň s povoláním Válečníka, zatímco ti zůstanou všechny úspěchy, celá sbírka a vybavení. Tvoje úkoly zůstanou i s historií, ale vrátí se na žlutou barvu. Tvé řady úspěchů se resetují, kromě úkolů patřících do aktivních výzev či do Skupiny. Tvé zlato, zkušenosti, mana a efekty všech schopností budou odstraněny. Toto vše nastane s okamžitou platností.",
"rebirthName": "Koule znovuzrození",
"rebirthComplete": "Byl jste znovuzrozen!",
"nextFreeRebirth": "<strong><%= days %> dni</strong> do <strong>bezplatného</strong> Koule znovuzrození"
+1 -1
View File
@@ -5,7 +5,7 @@
"keepIt": "Behold den",
"removeIt": "Fjern den",
"brokenChallenge": "Defekt udfordringslink: denne opgave var en del af en udfordring, men udfordringen (eller gruppen) er blevet fjernet. Hvad vil du gøre med de gruppeløse opgaver?",
"challengeCompleted": "Denne udfordring er afsluttet, og vinderen blev <span class=\"badge\"><%- user %></span>! Hvad vil du gøre med de gruppeløse opgaver?",
"challengeCompleted": "Denne udfordring er afsluttet, og vinderen blev <span class=\"badge\"><%= user %></span>! Hvad vil du gøre med de gruppeløse opgaver?",
"unsubChallenge": "Defekt Udfordringslink: denne opgave var en del af en udfordring, som du ikke længere abonnerer på. Hvad vil du gøre med de gruppeløse opgaver?",
"challenges": "Udfordringer",
"endDate": "Afsluttes",

Some files were not shown because too many files have changed in this diff Show More