Compare commits
669 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 213bbb21c1 | |||
| dd3ae4da4e | |||
| 681d2ab90b | |||
| 24a645277e | |||
| fa34922cce | |||
| 89c71ed073 | |||
| f49909dedf | |||
| ab43225f0c | |||
| 2bb1b07dd6 | |||
| f93c759bf2 | |||
| ef4ac2e3f4 | |||
| 32b36b2f54 | |||
| dee5c82fa8 | |||
| 22d66a28d7 | |||
| 984a56c412 | |||
| 207e7a13a2 | |||
| cc7feebbb0 | |||
| 925619b13c | |||
| ceb7bbc718 | |||
| 53a607fa53 | |||
| e9eeebc4b1 | |||
| b42d241882 | |||
| 68da609a9e | |||
| 1afa78ae39 | |||
| e0ff462f12 | |||
| f4e38123e4 | |||
| eb1c873b9a | |||
| 22f13c1505 | |||
| cbfc8f149f | |||
| 2e41859747 | |||
| 3b176a3e8f | |||
| a1e1e1d57f | |||
| eb973cc20b | |||
| f66ab92e51 | |||
| 4d573ffaa8 | |||
| 081189886a | |||
| 1efc8de880 | |||
| 8bf9db382e | |||
| 103b9c71bf | |||
| e27057788b | |||
| 4983b3c1ef | |||
| 197ab6c28a | |||
| fd0d47160d | |||
| 4697d269bc | |||
| 73bf03cfab | |||
| c3d4d5f06e | |||
| 4c201cc2d3 | |||
| c30a6a7bcd | |||
| c4354774ad | |||
| 8a44f77fb1 | |||
| 9ebd9a304f | |||
| b223a9c1f2 | |||
| 2d1a3ff6f5 | |||
| 90bd10d87a | |||
| 280bcbd5ab | |||
| 65ecfca05e | |||
| 91f5afc110 | |||
| 1c980fb039 | |||
| e93c665123 | |||
| 6be49ec14a | |||
| 793b408e3f | |||
| 213e8abf28 | |||
| bac2f3a5c7 | |||
| 1e38d9d2a2 | |||
| 419c1ceb48 | |||
| f6bde5871a | |||
| 9c56a7f987 | |||
| 2e8efab2aa | |||
| 0f45ce743f | |||
| 7794cd5dbd | |||
| c0cb6454ac | |||
| 2f45a9bbf5 | |||
| 11a61322e8 | |||
| cb1bc1a865 | |||
| 622cb14813 | |||
| 4afea98e77 | |||
| 79f3cc85dd | |||
| 4052f865c9 | |||
| 5887f790c6 | |||
| 6fc5d3ed97 | |||
| 0eaf30cd8b | |||
| f1d5e8d4ca | |||
| 7763aa2e0a | |||
| 500f06b538 | |||
| 85227c2175 | |||
| a570b318d7 | |||
| 99e32d9491 | |||
| 74022e8181 | |||
| d0b5164e6d | |||
| defc39c0f3 | |||
| a9844b3a4f | |||
| 77b8498850 | |||
| 4c34aba66d | |||
| 2bf4ed2af8 | |||
| 5afeac3c14 | |||
| 39e3c0b30f | |||
| d749718584 | |||
| 922a66835a | |||
| 0d4a96e785 | |||
| a3e10bc12b | |||
| 49c482f2ba | |||
| 0ad7a7892b | |||
| 989b423714 | |||
| 13f703a3ec | |||
| aa7c8e038b | |||
| 0469b6cec9 | |||
| ef88ca4235 | |||
| 1adbe1c98a | |||
| b97299ce0a | |||
| 93eeffb1ad | |||
| 081ad9240f | |||
| 7d3b92048b | |||
| 3c425a4e68 | |||
| 4ae90080e8 | |||
| 2cdcd543a4 | |||
| 71f8ee0e16 | |||
| 92634705b3 | |||
| 7aee4fe712 | |||
| 0e4ce974f0 | |||
| 4ddcee95d9 | |||
| 4e1f7b6007 | |||
| 00f3deb5b2 | |||
| b8037c48e9 | |||
| a3dfe25d13 | |||
| 50a834c4fc | |||
| f00332fca5 | |||
| 384936f106 | |||
| 81966dac0d | |||
| 7c8e4f1735 | |||
| b9b9363468 | |||
| 11ecfb1bcf | |||
| 605f4e52fe | |||
| a45e649374 | |||
| 3f32c95b35 | |||
| 2919bdf691 | |||
| 6192dfc568 | |||
| de57399301 | |||
| c6e6326b50 | |||
| cf59b6d0da | |||
| d836b1f068 | |||
| 6071a28dd9 | |||
| 03fa16ded2 | |||
| b8eb0a8549 | |||
| 5dac0214ea | |||
| 3ddb7c8ceb | |||
| 03d4b6c4f2 | |||
| 55b551f214 | |||
| 6fe17c1cfd | |||
| 93ccb572e5 | |||
| 03aa1e6dbc | |||
| 059fb67d26 | |||
| eec7f1d5b5 | |||
| 5ab16fbbf3 | |||
| a74f7037ff | |||
| 18cf251c7e | |||
| 5de5488b24 | |||
| 83887b0516 | |||
| ec24c4cfae | |||
| 002461e7cb | |||
| d12e75ae5c | |||
| a480379fa5 | |||
| c37d0d15a6 | |||
| 79ccfd661a | |||
| 67e8c23020 | |||
| 94f0c8308d | |||
| c77b68eed2 | |||
| 80820ae9c4 | |||
| 5288b7a718 | |||
| 4643830512 | |||
| ef04de67c0 | |||
| 5847cceba6 | |||
| f62b86027c | |||
| 8e5018d3b2 | |||
| 8df17f5ae7 | |||
| dd31ce681f | |||
| fd9a963b27 | |||
| 672d252492 | |||
| bc4e00520e | |||
| d777d1bc98 | |||
| 4cd97124da | |||
| 74345fdb2f | |||
| 7e7abdee3d | |||
| 9ed2127494 | |||
| 30608ae8ed | |||
| ae43014cf2 | |||
| ea8d3dd0f3 | |||
| 02231ea1f9 | |||
| effc704613 | |||
| 51fb1fd1cb | |||
| 1988e1b849 | |||
| 1b782f65d1 | |||
| 6c4eddece7 | |||
| cf0524a211 | |||
| a796f279a5 | |||
| efc491bad4 | |||
| 8d04bbbdbe | |||
| f6c08f8afa | |||
| 3197c53fcc | |||
| 7b793149b3 | |||
| 24c938728a | |||
| 1c358a3c79 | |||
| 3bbed8875c | |||
| a3e6ff34db | |||
| 0f7fc673eb | |||
| 646c95a86f | |||
| 87a8974c8c | |||
| 9b5df28b93 | |||
| e876e290da | |||
| f26f033b14 | |||
| 565f323179 | |||
| 82b2aeb294 | |||
| 6b59658f00 | |||
| 2e1e4416b3 | |||
| e92a2c571c | |||
| d3a418b5ee | |||
| c9945107e9 | |||
| 4c70133ca9 | |||
| 0df942cb9d | |||
| 37a63f068b | |||
| cc8638e8b2 | |||
| fd20081ce8 | |||
| cf9d409166 | |||
| 7d8ac49fe2 | |||
| dc3be6564b | |||
| 337e27f2b5 | |||
| c400437662 | |||
| e8941e8ef6 | |||
| 169823980f | |||
| aa7376b357 | |||
| 6a0e88cbf1 | |||
| 01b7e1cea2 | |||
| 910f43e0a5 | |||
| 6bf630bb40 | |||
| 5e71b3f44a | |||
| 5ffab157d7 | |||
| c6e791d18f | |||
| a80b306248 | |||
| c8c294a8ad | |||
| 5a30376f2c | |||
| 373219ecfa | |||
| 1ef1400699 | |||
| 7966d07158 | |||
| 9ffab3d2dd | |||
| dbcbd8928b | |||
| a659611897 | |||
| 78b4716a2a | |||
| 08e26e28d0 | |||
| b1c61a7888 | |||
| e951a3b00a | |||
| 62b5aab753 | |||
| 7b307ffe22 | |||
| edee9f7030 | |||
| 71949890da | |||
| 5ae233ff62 | |||
| 19400a78e5 | |||
| 497d6979d0 | |||
| 59eab8afea | |||
| 74b84eb5ac | |||
| bfc864cc7c | |||
| 6c067a3ae6 | |||
| 503fed5fdb | |||
| 32cb3eeba3 | |||
| 7e49e85495 | |||
| c3d7984d7a | |||
| b024518f5e | |||
| 83c1e9aa6c | |||
| 8a6cb02dc0 | |||
| 91237c252c | |||
| f7821451c7 | |||
| 9056b43696 | |||
| cdf54e9eff | |||
| 7a49e9646c | |||
| 2189f5e7c4 | |||
| 2822b4c159 | |||
| 3dd2591709 | |||
| ab2145ffe9 | |||
| 3ee880d1dd | |||
| 586e103161 | |||
| 5776bf2a51 | |||
| ceb442ebf1 | |||
| e6a2bdc65f | |||
| c9205adbab | |||
| 29d56daab3 | |||
| 4d7ac5e619 | |||
| fb3686fef4 | |||
| ed14ef0cd9 | |||
| fd90f90cbb | |||
| b6cee104b9 | |||
| 5c6df95734 | |||
| 8941aca968 | |||
| e1348f782e | |||
| b652976784 | |||
| 530c0681d0 | |||
| e38f57f823 | |||
| f3b9eb9f73 | |||
| bd7be9590a | |||
| e1fa43c9f0 | |||
| ccb0d9ec71 | |||
| eca4a5ba77 | |||
| 6dd29c571f | |||
| 28f1e2b517 | |||
| 21374b2cb4 | |||
| ada87468cc | |||
| 0a4b488d69 | |||
| aa257b34ec | |||
| c48e6c7123 | |||
| 827bc4b836 | |||
| c7d115f873 | |||
| 45c585a27d | |||
| dc3fe02767 | |||
| a1ef06510e | |||
| 56002c68ca | |||
| 30bd73f8f9 | |||
| 9d8a30f678 | |||
| 2b0b99d598 | |||
| 8551852c9d | |||
| 7ade0eaeb1 | |||
| debdbf770b | |||
| 01976685e8 | |||
| bac5d71480 | |||
| 0ad655d1cf | |||
| a526e301da | |||
| 9c16c6df40 | |||
| 3220e9482b | |||
| 9a033d7f91 | |||
| 2329458a84 | |||
| c613a7aedd | |||
| d4d502f418 | |||
| 7f37f16c7b | |||
| a67d007435 | |||
| a5c6645d2d | |||
| cbe50a0232 | |||
| a48ac48202 | |||
| 0a7aaca6e5 | |||
| 6e41ea3b42 | |||
| 75ada621d9 | |||
| 12d578ff57 | |||
| 3486b7f503 | |||
| 0aed5e0f31 | |||
| 34c40980e3 | |||
| b13eb6012c | |||
| ecc3284a94 | |||
| b10c8ff182 | |||
| d96e222a15 | |||
| ec63533108 | |||
| 6f74366dd9 | |||
| 4b5825790a | |||
| c257e61fa7 | |||
| f7391c0e0b | |||
| dce3d5b411 | |||
| a2490da3b4 | |||
| 0bd4877dd3 | |||
| 1aacc0073f | |||
| 6195ae6901 | |||
| 6f2d80b99e | |||
| a8e7901eac | |||
| d4ae9d9611 | |||
| d82a3cffe8 | |||
| 05e189b938 | |||
| d32d0b17d0 | |||
| 3a8282255c | |||
| 79e97fae09 | |||
| 9b93881663 | |||
| 21df47eccb | |||
| 1d87315426 | |||
| 4a1e21e820 | |||
| a8181c45d0 | |||
| 84ca17ebc4 | |||
| 4db0e8870d | |||
| 310993d57c | |||
| d624b93d8c | |||
| 872d319220 | |||
| cd44ae6bc0 | |||
| deb59b314b | |||
| 4c75d4f559 | |||
| c5bc900212 | |||
| 8bd2bca879 | |||
| 27283384bf | |||
| 9901635008 | |||
| 1f5ce2546c | |||
| 2f0adcce7c | |||
| 7bfab65042 | |||
| b59eeeca81 | |||
| 81e42f24c8 | |||
| d4a928b682 | |||
| da27054a9b | |||
| 3c54cd27fe | |||
| 8fe8525b06 | |||
| 9169cd5d1f | |||
| 490b8554e2 | |||
| 97748dfd34 | |||
| 0d8b320f31 | |||
| 32b0cef65d | |||
| abcb51c0e2 | |||
| 8d02645e26 | |||
| 6ab05471b2 | |||
| f82adab05d | |||
| 1291a0e932 | |||
| a2d40c5cbf | |||
| 17954e0504 | |||
| 2e5e6c9ad3 | |||
| c1e9143483 | |||
| 774f7d2dbe | |||
| de9eab1e4e | |||
| 9b1925d6de | |||
| a6ad1bdcdb | |||
| f9c8bbc4cc | |||
| 3634440c8b | |||
| 488ce5750d | |||
| c963673a19 | |||
| 6e2589e125 | |||
| d980fdf96d | |||
| 89fe5b8937 | |||
| 93bc669f24 | |||
| 0602c1b59d | |||
| 6e2716d957 | |||
| 9c3ec58246 | |||
| d8a81879b1 | |||
| c054bc7bc7 | |||
| c46e7b98e0 | |||
| 65762e8645 | |||
| 689ac34946 | |||
| daf35f6e41 | |||
| 58a5c470bd | |||
| aecddf6fb5 | |||
| f702513a64 | |||
| 3126ad2380 | |||
| d947a951ad | |||
| 51fffc0ae1 | |||
| 273cf1094d | |||
| 403946bac5 | |||
| cb81fd3315 | |||
| 95db4e4dcf | |||
| c5fb019702 | |||
| c907779b3c | |||
| c1b33e17c8 | |||
| 5fed2f4182 | |||
| b2f62e12c7 | |||
| 79571cc5b3 | |||
| 3e6b947893 | |||
| cc59035c62 | |||
| 9b1615480f | |||
| ad59299581 | |||
| 4ac8651cc8 | |||
| a11be64d94 | |||
| a32c620b4e | |||
| 7a8fbe3ee5 | |||
| 13480d528a | |||
| 7ddaf135b4 | |||
| 848ac15ef0 | |||
| ae1f97eb08 | |||
| a2fa2a6b96 | |||
| 2d9ff34ded | |||
| d1a85659ba | |||
| d832e6e364 | |||
| 08a5c808f8 | |||
| 66cfe9ee45 | |||
| c2b14e4f07 | |||
| 628dd47772 | |||
| f8612ee20e | |||
| 172bebe24a | |||
| 3c45641ef4 | |||
| 5eb6af1ab6 | |||
| c1b48058d5 | |||
| 33ebeec2ac | |||
| a3e5ff9f4a | |||
| 203ef9dd44 | |||
| bdfb8f9dc6 | |||
| 865fabce98 | |||
| e530e38721 | |||
| d98ae9cdbf | |||
| 5100b76ad3 | |||
| 8328af802f | |||
| 87914291c6 | |||
| 0c7daef65e | |||
| 09da778d3b | |||
| 2ad64bbca7 | |||
| 247b94f3b3 | |||
| 8078ad5609 | |||
| 7fd70ac0d9 | |||
| dbdaff2ada | |||
| 3e53e368a4 | |||
| 6a7c037ea8 | |||
| 2414441efa | |||
| 14deb86a7a | |||
| 572c3b082e | |||
| d2b466df93 | |||
| 3f11465a7e | |||
| ece9a37af4 | |||
| 7f4cf8bdcd | |||
| d6538aac50 | |||
| 3a47fccf16 | |||
| c659eaeead | |||
| 37c7a37bdf | |||
| d3f23544cc | |||
| 9d5ea22806 | |||
| 1388d0e514 | |||
| e3412fac46 | |||
| e43c0b1e2e | |||
| b13a5ae1ae | |||
| 8c52848212 | |||
| 053007e7ea | |||
| 9889cb07d4 | |||
| 142b144318 | |||
| a47e53ff2d | |||
| 25bfe4f0fa | |||
| 8b88cd3cf6 | |||
| 748bf43847 | |||
| 586c536c46 | |||
| 4a84a782db | |||
| e2dbc0e1cf | |||
| 2b434de40b | |||
| a51c174021 | |||
| c1373174ca | |||
| c3f0ecf7d5 | |||
| 2f2fdb1809 | |||
| 95a123532b | |||
| 2cc7c7bcaf | |||
| b314b98dd6 | |||
| 5ec79e9612 | |||
| 9e89972008 | |||
| 05864d001a | |||
| f9fc81ce71 | |||
| 66a23cc99b | |||
| aa2d724a13 | |||
| 67f840c0ec | |||
| f2e545ff09 | |||
| 11142bc96a | |||
| e3d01bc6aa | |||
| d3fc1c602a | |||
| 926ad380f3 | |||
| b55a9bae43 | |||
| f8c46d7a11 | |||
| 75ca14c900 | |||
| 163712471c | |||
| 974cdcccc9 | |||
| bbe53a4c69 | |||
| 88d9e783b8 | |||
| 854f9aca23 | |||
| 69dde41d9c | |||
| e159e5bb6d | |||
| fce3c81029 | |||
| b14717eddb | |||
| 5891014ff6 | |||
| b51535bfa0 | |||
| 8476edd18f | |||
| e528cdb36d | |||
| 8696c698ed | |||
| c236caefad | |||
| f9de5282c9 | |||
| a4c2895c68 | |||
| e33ee800bc | |||
| 24aa80840c | |||
| d9aa6258cd | |||
| 941e6ee4e6 | |||
| ffab9d3aaa | |||
| 4d16e1ab83 | |||
| 6781685252 | |||
| 88bdf87e95 | |||
| 6b3d98bd66 | |||
| ec7ceb2352 | |||
| c62c38b136 | |||
| 33bf59f353 | |||
| b4d3c4833c | |||
| 44b54f6c32 | |||
| 57f7af3141 | |||
| 23dbd9112a | |||
| 44c8103600 | |||
| d2441b345b | |||
| 2c828c8778 | |||
| 8097f0e5fb | |||
| 14644d0cb3 | |||
| 4c89a20bbe | |||
| 6a2285ef72 | |||
| 37a791f113 | |||
| 06f820d355 | |||
| 3a2571ccc6 | |||
| dd614e6a6a | |||
| f5595b3477 | |||
| e9c34df51e | |||
| 82b9964a61 | |||
| a0cb5cc307 | |||
| 0b26ef51a2 | |||
| da3cc5f997 | |||
| 96356eb804 | |||
| 80067a212c | |||
| d962d7952b | |||
| ae4847ce50 | |||
| a07a2de786 | |||
| 96ce34c7f1 | |||
| c6fa1acf66 | |||
| a7c29c4a85 | |||
| 91364385c3 | |||
| d958722e63 | |||
| d3a19ebfaa | |||
| c17883bdb8 | |||
| 0b90b0206b | |||
| 8f958ef6c7 | |||
| 2118fa483b | |||
| 2bb9c7738a | |||
| 7ed55b00a6 | |||
| 4feb051177 | |||
| 27bce0d334 | |||
| ddf50724f0 | |||
| d07bd75d07 | |||
| d59ba03cc6 | |||
| 04112110f7 | |||
| d835cb5e6a | |||
| 6e5a6b5d91 | |||
| 270cb51acc | |||
| 579c78b2ad | |||
| 470cdd1c76 | |||
| 2abbae38d9 | |||
| 46dc8d9e12 | |||
| 59cbb9d740 | |||
| c50c9bec7e | |||
| 14ebcb1165 | |||
| 3728ad02e6 | |||
| ba9e1f5375 | |||
| 598c5f90ea | |||
| 6e1a195615 | |||
| 452848f14f | |||
| 4ccc123209 | |||
| a987449789 | |||
| ba9ff0964b | |||
| 3cd64fb0af | |||
| 6e8e6fe243 | |||
| 476f1bade2 | |||
| cf1d9ad53f | |||
| 680ff86202 | |||
| 9797fcd95a | |||
| 5d3841d6a7 | |||
| 9e5de53ad4 | |||
| 13cdbc565c | |||
| 5ea4b0f73d | |||
| f3e262bd3a | |||
| 96b8288c5b | |||
| a85590f5fb | |||
| 5a93bdd0a6 | |||
| be582f4db7 | |||
| 01a174f9e3 | |||
| ecc306079b | |||
| 251ea43e33 | |||
| 37b8fc6752 | |||
| 17b986c21d | |||
| d6b3dbc9f9 | |||
| 1f41478d53 | |||
| bd71520cb5 | |||
| 6276d135e4 | |||
| 3abdbc2d88 | |||
| 241f234a82 | |||
| 28ee5b6881 | |||
| d92790f0af | |||
| e18d0592d6 | |||
| 11802fc38a | |||
| b2ae35d597 | |||
| ec37a8befe | |||
| 804dd550a2 | |||
| 736d76f457 | |||
| 84734d7304 | |||
| e19b99a2bc | |||
| 577334cbaa | |||
| 35b615dc9f | |||
| ef8e9a3ccf | |||
| 27d5544f8b | |||
| e98111bf00 | |||
| c8c68f1898 | |||
| f47ccbec51 | |||
| ba53bc05a4 |
@@ -9,14 +9,14 @@ This skill guides you through publishing a new release of the app. It handles ve
|
||||
|
||||
## Overview
|
||||
|
||||
- **Version format**: Semantic versioning (X.Y.Z), starting from 2.0.0
|
||||
- **Version format**: Marketing version (X.Y.Z), starting from 2.0.0. **This is NOT semver.** Version numbers are chosen based on how the release looks to end users, not based on API compatibility or breaking changes. Think of it like an app store version -- the number reflects the perceived significance of the update to a regular user.
|
||||
- **Version source of truth**: `package.json` `version` field
|
||||
- **Changelog**: `CHANGELOG.md` in repo root, using [Keep a Changelog](https://keepachangelog.com/) format
|
||||
- **Version bumping**: Marketing-driven (not strict semver)
|
||||
- **Patch (Z)**: Bug fixes, minor tweaks, dependency updates, small UI adjustments
|
||||
- **Minor (Y)**: New user-facing features, significant UI changes, new pages/screens
|
||||
- **Version bumping**:
|
||||
- **Patch (Z)**: Most releases. Bug fixes, tweaks, internal improvements, anything a user wouldn't specifically notice or seek out.
|
||||
- **Minor (Y)**: Releases with headline features -- things worth announcing. A user should be able to look at the minor bump and think "oh, something new happened."
|
||||
- **Major (X)**: Only when the user explicitly requests it (milestones, rebrands, major redesigns)
|
||||
- **CI trigger**: Pushing a semver tag (`v2.1.0`) triggers the CI pipeline to build APKs, create a GitLab release, and publish to Zapstore
|
||||
- **CI trigger**: Pushing a version tag (`v2.1.0`) triggers the CI pipeline to build APKs, create a GitLab release, and publish to Zapstore
|
||||
|
||||
## Release Procedure
|
||||
|
||||
@@ -69,11 +69,11 @@ Analyze the commits from Step 3 and determine the appropriate bump level:
|
||||
|
||||
| Bump | When to use | Example |
|
||||
|------|-------------|---------|
|
||||
| **Patch** | Bug fixes, minor tweaks, dependency updates, small UI polish | 2.0.0 -> 2.0.1 |
|
||||
| **Minor** | New user-facing features, new screens/pages, significant UI changes | 2.0.1 -> 2.1.0 |
|
||||
| **Patch** | Bug fixes, minor tweaks, dependency updates, small UI polish, internal tooling, developer-facing pages, CI/build changes, settings/admin screens | 2.0.0 -> 2.0.1 |
|
||||
| **Minor** | Significant new product features that change how users interact with the app -- the kind of thing you'd highlight in an app store update or announce on social media (e.g., new content type support, DM redesign, new social features, theme system overhaul) | 2.0.1 -> 2.1.0 |
|
||||
| **Major** | ONLY when the user explicitly instructs a major bump | 2.1.0 -> 3.0.0 |
|
||||
|
||||
**Default to patch** when in doubt. Choose minor if there are clearly new features. Never auto-bump major.
|
||||
**Default to patch** when in doubt. The bar for a minor bump is high -- ask yourself: "Would a regular user notice and care about this change?" If the answer is no, it's a patch. Internal pages (changelog, settings, about screens), infrastructure improvements, CI fixes, and developer tooling are always patch-level regardless of whether they technically add a new page or screen.
|
||||
|
||||
When bumping minor, reset patch to 0 (e.g., 2.0.3 -> 2.1.0).
|
||||
When bumping major, reset minor and patch to 0 (e.g., 2.3.1 -> 3.0.0).
|
||||
@@ -108,6 +108,10 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
|
||||
- Focus on what the user sees/experiences, not internal implementation details
|
||||
- Use the current date in YYYY-MM-DD format
|
||||
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
|
||||
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
|
||||
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
|
||||
|
||||
### Step 6: Update Version in All Files
|
||||
|
||||
@@ -131,24 +135,44 @@ versionName "X.Y.Z"
|
||||
|
||||
#### 6c. `ios/App/App.xcodeproj/project.pbxproj`
|
||||
|
||||
Update `MARKETING_VERSION` in all 4 occurrences (2 Debug configs + 2 Release configs):
|
||||
Update `MARKETING_VERSION` in all occurrences (Debug + Release configs):
|
||||
|
||||
```
|
||||
MARKETING_VERSION = X.Y.Z;
|
||||
```
|
||||
|
||||
**Important:** There are exactly 4 lines containing `MARKETING_VERSION` in this file. All 4 must be updated to the same value. Use a replaceAll operation.
|
||||
**Important:** All lines containing `MARKETING_VERSION` must be updated to the same value. Use a replaceAll operation.
|
||||
|
||||
Do NOT change `CURRENT_PROJECT_VERSION` -- it stays at `1` (may be managed separately for App Store submissions in the future).
|
||||
|
||||
### Step 7: Commit the Release
|
||||
### Step 7: Copy Changelog to Public Directory
|
||||
|
||||
The changelog is served at runtime by the app from the `public/` directory. After updating `CHANGELOG.md`, copy it:
|
||||
|
||||
```bash
|
||||
git add package.json CHANGELOG.md android/app/build.gradle ios/App/App.xcodeproj/project.pbxproj
|
||||
cp CHANGELOG.md public/CHANGELOG.md
|
||||
```
|
||||
|
||||
### Step 8: Pull Latest Changes
|
||||
|
||||
Before committing the release, pull the latest changes from the remote to ensure the release commit sits on top of the latest code. This **must** happen before committing and tagging.
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
**CRITICAL**: Always use `git pull` (merge), NEVER `git pull --rebase`. Rebasing rewrites commit hashes, which would orphan any tag pointing to the original commit. Since version tags are often protected on the remote and cannot be deleted or updated, a broken tag cannot be easily fixed.
|
||||
|
||||
If there are merge conflicts with the pulled changes, resolve them before proceeding.
|
||||
|
||||
### Step 9: Commit the Release
|
||||
|
||||
```bash
|
||||
git add package.json CHANGELOG.md public/CHANGELOG.md android/app/build.gradle ios/App/App.xcodeproj/project.pbxproj
|
||||
git commit -m "release: vX.Y.Z"
|
||||
```
|
||||
|
||||
### Step 8: Tag the Release
|
||||
### Step 10: Tag the Release
|
||||
|
||||
```bash
|
||||
git tag vX.Y.Z
|
||||
@@ -156,18 +180,20 @@ git tag vX.Y.Z
|
||||
|
||||
The tag format is `v` followed by the semver version with no suffix. Examples: `v2.0.0`, `v2.1.0`, `v2.1.1`.
|
||||
|
||||
### Step 9: Push
|
||||
### Step 11: Push
|
||||
|
||||
```bash
|
||||
git push origin main --tags
|
||||
git push origin main vX.Y.Z
|
||||
```
|
||||
|
||||
**CRITICAL**: Push only the specific tag being released. NEVER use `--tags` -- that pushes ALL local tags, including stale or deleted ones.
|
||||
|
||||
This triggers the GitLab CI pipeline which will:
|
||||
1. Build a signed Android APK and AAB
|
||||
2. Create a GitLab Release with download links
|
||||
3. Publish the APK to Zapstore
|
||||
|
||||
### Step 10: Confirm
|
||||
### Step 12: Confirm
|
||||
|
||||
After pushing, inform the user:
|
||||
- The new version number
|
||||
@@ -180,8 +206,9 @@ After pushing, inform the user:
|
||||
|------|---------------|-------|
|
||||
| `package.json` | `version` field | Source of truth for the version |
|
||||
| `CHANGELOG.md` | Prepend new section | User-facing changelog |
|
||||
| `public/CHANGELOG.md` | Copy from `CHANGELOG.md` | Served at runtime by the app |
|
||||
| `android/app/build.gradle` | `versionName` on line 17 | `versionCode` is managed by CI |
|
||||
| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (4 occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 |
|
||||
| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (all occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 |
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
@@ -205,7 +232,7 @@ If you tagged the wrong version and haven't pushed yet:
|
||||
git tag -d vX.Y.Z # delete the local tag
|
||||
git reset --soft HEAD~1 # undo the commit but keep changes staged
|
||||
```
|
||||
Then redo steps 4-9 with the correct version.
|
||||
Then redo steps 4-10 with the correct version.
|
||||
|
||||
### Already pushed a bad release
|
||||
This requires manual intervention. Inform the user and suggest they delete the tag and release from GitLab manually, then re-run the release process.
|
||||
|
||||
@@ -26,19 +26,37 @@ test:
|
||||
script:
|
||||
- npm run test
|
||||
|
||||
pages:
|
||||
deploy-nsite:
|
||||
stage: deploy
|
||||
timeout: 5 minutes
|
||||
timeout: 10 minutes
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
variables:
|
||||
NSYTE_VERSION: "v0.24.1"
|
||||
script:
|
||||
# Build the web app
|
||||
- npm ci
|
||||
- npm run build
|
||||
- rm -rf public
|
||||
- mv dist public
|
||||
artifacts:
|
||||
paths:
|
||||
- public
|
||||
only:
|
||||
variables:
|
||||
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
|
||||
- cp dist/index.html dist/404.html
|
||||
|
||||
# Download nsyte binary
|
||||
- curl -fsSL "https://github.com/sandwichfarm/nsyte/releases/download/${NSYTE_VERSION}/nsyte-linux" -o /usr/local/bin/nsyte
|
||||
- chmod +x /usr/local/bin/nsyte
|
||||
|
||||
# Deploy to nsite via nsyte using the nbunksec credential
|
||||
- >-
|
||||
nsyte deploy ./dist
|
||||
-i
|
||||
--sec "$NSITE_NBUNKSEC"
|
||||
--name ditto
|
||||
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
|
||||
--fallback "/index.html"
|
||||
--publish-server-list
|
||||
--use-fallback-relays
|
||||
--use-fallback-servers
|
||||
|
||||
build-apk:
|
||||
stage: build
|
||||
@@ -162,23 +180,18 @@ release:
|
||||
if [ -z "$RELEASE_NOTES" ]; then
|
||||
RELEASE_NOTES="Ditto ${CI_COMMIT_TAG}"
|
||||
fi
|
||||
echo "RELEASE_NOTES<<ENDOFNOTES" >> release.env
|
||||
echo "$RELEASE_NOTES" >> release.env
|
||||
echo "ENDOFNOTES" >> release.env
|
||||
artifacts:
|
||||
reports:
|
||||
dotenv: release.env
|
||||
- echo "$RELEASE_NOTES" > release-notes.md
|
||||
release:
|
||||
tag_name: $CI_COMMIT_TAG
|
||||
name: $CI_COMMIT_TAG
|
||||
description: $RELEASE_NOTES
|
||||
description: './release-notes.md'
|
||||
assets:
|
||||
links:
|
||||
- name: "Ditto-${CI_COMMIT_TAG}.apk"
|
||||
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk"
|
||||
- name: Ditto-${CI_COMMIT_TAG}.apk
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk
|
||||
link_type: package
|
||||
- name: "Ditto-${CI_COMMIT_TAG}.aab"
|
||||
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab"
|
||||
- name: Ditto-${CI_COMMIT_TAG}.aab
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab
|
||||
link_type: package
|
||||
|
||||
publish-zapstore:
|
||||
@@ -203,4 +216,4 @@ publish-zapstore:
|
||||
- VERSION="${CI_COMMIT_TAG#v}"
|
||||
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
|
||||
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
|
||||
- zsp publish -y --skip-metadata --skip-preview zapstore.yaml
|
||||
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
|
||||
|
||||
@@ -12,6 +12,7 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
|
||||
- **React Router**: For client-side routing with BrowserRouter and ScrollToTop functionality
|
||||
- **TanStack Query**: For data fetching, caching, and state management
|
||||
- **TypeScript**: For type-safe JavaScript development
|
||||
- **Capacitor**: Native iOS and Android shell wrapping the web app
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -293,14 +294,16 @@ When adding support for a new Nostr event kind to the application, the kind must
|
||||
- `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers
|
||||
- The icon fallback in `AddressableEventPreview` in the same file
|
||||
|
||||
6. **Inline embeds / quote posts** -- events can be quoted inline via `nostr:nevent1...` or `nostr:naddr1...` URIs in note content. Both `EmbeddedNote` and `EmbeddedNaddr` render a compact card (author + title/content preview) for all kinds automatically — no per-kind registration needed. The same components are reused by CommentContext hover cards and the reply composer.
|
||||
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) -- these are the small preview cards shown inside quote posts, reply context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal card (author + title/content preview + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags rather than in the `content` field (e.g. kind 20 photos via `imeta` tags) may need attachment indicator logic added to `EmbeddedNoteCard`.
|
||||
|
||||
> **Note**: Do not confuse these with the `compact` prop on `NoteCard`. The `compact` prop simply hides action buttons on a full `NoteCard`; `EmbeddedNote`/`EmbeddedNaddr` are entirely different components with their own rendering logic.
|
||||
|
||||
7. **Reply composer** (`src/components/ReplyComposeModal.tsx`):
|
||||
- The `EmbeddedPost` component delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components — no per-kind registration needed
|
||||
|
||||
#### Why so many places?
|
||||
|
||||
These are genuinely different UI contexts (feed cards, detail pages, inline embeds, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
|
||||
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
|
||||
|
||||
### NIP.md
|
||||
|
||||
@@ -692,6 +695,64 @@ export function MyComponent() {
|
||||
|
||||
The `useCurrentUser` hook should be used to ensure that the user is logged in before they are able to publish Nostr events.
|
||||
|
||||
### Mutating Replaceable Events (CRITICAL)
|
||||
|
||||
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a read-modify-write cycle: fetch the current event, modify its tags, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
|
||||
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
|
||||
|
||||
```typescript
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
// Inside a mutation function:
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
const currentTags = freshEvent?.tags ?? [];
|
||||
// ...modify tags...
|
||||
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
|
||||
```
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
### D-Tag Collision Prevention for Addressable Events
|
||||
|
||||
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
|
||||
|
||||
#### When to Check for Collisions
|
||||
|
||||
**Must check before publishing** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.). **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
|
||||
|
||||
#### Implementation Pattern
|
||||
|
||||
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
|
||||
|
||||
```typescript
|
||||
// Before publishing a new addressable event:
|
||||
const slug = slugify(title, { lower: true, strict: true });
|
||||
|
||||
const existing = await nostr.query([
|
||||
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
|
||||
]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
toast({
|
||||
title: 'Slug already in use',
|
||||
description: 'Change the slug or edit the existing item.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe to publish
|
||||
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
|
||||
```
|
||||
|
||||
**Skip the check in edit mode** -- when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
|
||||
|
||||
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
|
||||
|
||||
### Nostr Login
|
||||
|
||||
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
|
||||
@@ -953,6 +1014,16 @@ const defaultConfig: AppConfig = {
|
||||
|
||||
The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations.
|
||||
|
||||
### Adding a New AppConfig Value
|
||||
|
||||
Adding a new configuration field requires updates in **three places**. Missing any of them will cause build failures or runtime issues.
|
||||
|
||||
1. **TypeScript interface** (`src/contexts/AppContext.ts`): Add the field to the `AppConfig` interface with a JSDoc comment.
|
||||
|
||||
2. **Zod schema** (`src/lib/schemas.ts`): Add the same field to `AppConfigSchema`. The `DittoConfigSchema` (used to validate the build-time `ditto.json` file) is derived from `AppConfigSchema` with `.strict()` mode, so any field present in `ditto.json` but missing from the Zod schema will cause a build error.
|
||||
|
||||
3. **Default value** (`src/contexts/AppContext.ts`): If the field is required (not optional), add a default value in `defaultConfig`. Optional fields (`?` in the interface, `.optional()` in Zod) can be omitted from the default.
|
||||
|
||||
### Relay Management
|
||||
|
||||
The project includes a complete NIP-65 relay management system:
|
||||
@@ -1246,14 +1317,75 @@ If git is available in your environment (through a `shell` tool, or other git-sp
|
||||
|
||||
When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes.
|
||||
|
||||
**ALWAYS commit when you are finished making changes.**
|
||||
**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes.**
|
||||
|
||||
## Capacitor Compatibility
|
||||
|
||||
The app runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
|
||||
|
||||
### What Doesn't Work in WKWebView (iOS)
|
||||
|
||||
- **`<a download>` file downloads** -- Programmatically creating an anchor element with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
|
||||
- **`<a target="_blank">` new tabs** -- Programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
|
||||
- **`window.open()`** -- May be blocked or behave unexpectedly without user gesture context.
|
||||
|
||||
### File Downloads and URL Opening
|
||||
|
||||
The project provides two utility functions in `src/lib/downloadFile.ts` that handle the web/native split automatically:
|
||||
|
||||
#### `downloadTextFile(filename, content)`
|
||||
|
||||
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
|
||||
|
||||
```typescript
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
|
||||
await downloadTextFile('backup.txt', fileContents);
|
||||
```
|
||||
|
||||
#### `openUrl(url)`
|
||||
|
||||
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
|
||||
|
||||
```typescript
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
await openUrl('https://example.com/image.jpg');
|
||||
```
|
||||
|
||||
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. Always use the utilities above. They handle the Capacitor/web split and will work correctly on all platforms.
|
||||
|
||||
### Detecting Native Platforms
|
||||
|
||||
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
|
||||
|
||||
```typescript
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// iOS or Android
|
||||
} else {
|
||||
// Web browser
|
||||
}
|
||||
```
|
||||
|
||||
### Installed Capacitor Plugins
|
||||
|
||||
- `@capacitor/app` -- App lifecycle events (deep links, back button)
|
||||
- `@capacitor/core` -- Core runtime and platform detection
|
||||
- `@capacitor/filesystem` -- Read/write files on the native filesystem
|
||||
- `@capacitor/local-notifications` -- Schedule local push notifications
|
||||
- `@capacitor/share` -- Native share sheet
|
||||
- `@capacitor/status-bar` -- Control the native status bar style
|
||||
|
||||
After adding or removing plugins, run `npx cap sync` to update the native projects.
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
|
||||
|
||||
1. **test** - Runs `npm run test` on every commit (skipped for tags)
|
||||
2. **deploy** - Builds and deploys to GitLab Pages (default branch only)
|
||||
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
|
||||
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
|
||||
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
|
||||
@@ -1293,19 +1425,69 @@ NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and
|
||||
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
|
||||
|
||||
**Initial setup (one-time):**
|
||||
1. Generate a client key: `nak key generate` (save the hex output)
|
||||
2. Store it as `ZAPSTORE_CLIENT_KEY` in GitLab CI/CD variables
|
||||
3. Get a bunker URL from Amber (with `secret` param for first connection)
|
||||
4. Authorize the client key locally using `nak`:
|
||||
```bash
|
||||
export NOSTR_CLIENT_KEY="<the hex client key>"
|
||||
nak event --sec "bunker://<pubkey>?relay=...&secret=<secret>" -c "test"
|
||||
```
|
||||
5. Approve the connection on Amber when prompted
|
||||
6. Store the bunker URL **without the `secret` param** as `ZAPSTORE_BUNKER_URL` in GitLab CI/CD variables (the secret is single-use and no longer needed after authorization)
|
||||
|
||||
Run the NIP-46 client-initiated auth script:
|
||||
|
||||
```bash
|
||||
node scripts/nip46-auth.mjs
|
||||
```
|
||||
|
||||
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script will then output the `bunker://` URI and client key hex, and write the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
|
||||
|
||||
The script accepts options:
|
||||
- `--relay <url>` -- relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
|
||||
- `--name <name>` -- app name shown to the signer (default: `Ditto`)
|
||||
- `--timeout <sec>` -- how long to wait for approval (default: 300)
|
||||
|
||||
**Key points:**
|
||||
- The `secret` in bunker URLs is **single-use** -- it is consumed on first connection and cannot be reused
|
||||
- The `ZAPSTORE_CLIENT_KEY` must be authorized locally first by connecting to the bunker with a fresh secret and approving on Amber
|
||||
- After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs
|
||||
- If the client key is rotated, the authorization step must be repeated with a new bunker URL secret
|
||||
- If the client key is rotated, run the script again and update the GitLab CI/CD variables
|
||||
|
||||
### nsite Publishing
|
||||
|
||||
The project automatically deploys the web app to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The `deploy-nsite` CI job builds the Vite app and uploads the `dist/` directory to Blossom servers, publishing site manifest events to Nostr relays.
|
||||
|
||||
nsyte uses a NIP-46 bunker credential called `nbunksec` -- a bech32-encoded string that bundles the bunker pubkey, client secret key, and relay info into a single self-contained token. This is passed to nsyte via `--sec`.
|
||||
|
||||
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
|
||||
|
||||
#### Initial Setup (one-time)
|
||||
|
||||
1. Install nsyte locally:
|
||||
```bash
|
||||
curl -fsSL https://nsyte.run/get/install.sh | bash
|
||||
```
|
||||
|
||||
2. Generate the CI credential:
|
||||
```bash
|
||||
nsyte ci
|
||||
```
|
||||
This will guide you through connecting a NIP-46 bunker (e.g. Amber) and output an `nbunksec1...` string. The credential is shown only once.
|
||||
|
||||
3. Add the `nbunksec1...` value as the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
|
||||
|
||||
#### Configured Relays and Servers
|
||||
|
||||
The deploy job publishes to these relays:
|
||||
- `wss://relay.ditto.pub`
|
||||
- `wss://relay.nsite.lol`
|
||||
- `wss://relay.dreamith.to`
|
||||
- `wss://relay.primal.net`
|
||||
|
||||
And uploads blobs to these Blossom servers:
|
||||
- `https://blossom.primal.net`
|
||||
- `https://blossom.ditto.pub`
|
||||
- `https://blossom.dreamith.to`
|
||||
|
||||
The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
|
||||
|
||||
#### Credential Rotation
|
||||
|
||||
To rotate the nsite credential:
|
||||
1. Revoke the old bunker connection in your signer app
|
||||
2. Run `nsyte ci` again to generate a new `nbunksec1...` string
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
|
||||
@@ -1,5 +1,223 @@
|
||||
# Changelog
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
|
||||
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
|
||||
|
||||
### Changed
|
||||
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
|
||||
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
|
||||
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
|
||||
|
||||
### Fixed
|
||||
- Notification dot not clearing after marking notifications as read
|
||||
- Followers/following modal staying open after navigating to a profile
|
||||
|
||||
## [2.2.7] - 2026-03-31
|
||||
|
||||
### Fixed
|
||||
- Nushu script in encrypted letters now renders correctly on Android and iOS
|
||||
|
||||
## [2.2.6] - 2026-03-31
|
||||
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
- Badge detail page streamlined with unified tab bar
|
||||
|
||||
### Fixed
|
||||
- Hashtags now support accented and Unicode characters
|
||||
- Letter compose opens correctly from notifications and the letters page
|
||||
- Letter font picker loads fonts so each option previews in the correct typeface
|
||||
- Zap comment positioned inside the right column instead of floating with offset
|
||||
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
|
||||
|
||||
## [2.2.5] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Crash when dragging profile tabs to reorder them
|
||||
|
||||
## [2.2.4] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
|
||||
- Zap moved to the profile overflow menu so it's still one tap away
|
||||
|
||||
### Fixed
|
||||
- Crash on the notifications page caused by malformed badge award tags
|
||||
- Deleting a badge now also deletes all awards you issued for it
|
||||
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
|
||||
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
|
||||
- Profile reactions no longer collapse into a single grouped notification
|
||||
- Oversized reaction emoji in comment context headers
|
||||
|
||||
## [2.2.3] - 2026-03-30
|
||||
|
||||
### Added
|
||||
- Letters now have an overflow menu, reply button, and a grid layout for browsing
|
||||
- Independent feed toggles for comments and generic reposts in content settings
|
||||
- Sidebar items are now visible to logged-out users so newcomers can explore everything
|
||||
|
||||
### Changed
|
||||
- Compose textarea expands smoothly as you type instead of snapping to a new height
|
||||
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
|
||||
|
||||
### Fixed
|
||||
- Feed gaps when replies are disabled no longer cause missing posts
|
||||
- Avatar shape no longer flashes on load
|
||||
- Top bar arc no longer flickers during navigation transitions
|
||||
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
|
||||
- Notification rendering for badges and letters
|
||||
- Duplicate React keys in content settings
|
||||
- Layout rendering warning when switching views
|
||||
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
- Changelog link in the app footer
|
||||
|
||||
### Changed
|
||||
- "Vines" renamed to "Divines" everywhere in the app
|
||||
- Custom emojis appear first in the emoji picker, right after recent
|
||||
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
|
||||
|
||||
### Fixed
|
||||
- Delete post dialog no longer freezes the feed on desktop
|
||||
- Amber login on Android now properly retries when returning from the background
|
||||
- Key downloads on Android save to the correct location
|
||||
- Custom emoji SVGs render correctly in the emoji-mart picker
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
- Inconsistent use of "geocache" vs "treasures" terminology
|
||||
- Search page "N new posts" pill no longer shows unfiltered count
|
||||
- Stale-cache overwrites in replaceable event mutations
|
||||
- Click-through on delete confirmation and note menu items
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
|
||||
- Mobile header no longer shows double-layered backgrounds on notched devices
|
||||
- Pinned tabs stay properly positioned when scrolling on mobile
|
||||
- Signer approval toasts no longer fire in rapid succession on unstable connections
|
||||
- Toasts are easier to swipe away on mobile
|
||||
- Content warnings now blur thumbnails in the media grid
|
||||
|
||||
## [2.2.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
### Changed
|
||||
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
|
||||
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
|
||||
- Upgraded from React 18 to React 19
|
||||
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
|
||||
|
||||
### Fixed
|
||||
- Zapping Primal users no longer produces an error
|
||||
- Hashtag feeds now match case-insensitively for parity with search results
|
||||
- Mobile top bar arc no longer lingers on pages without tabs
|
||||
- Give Badge dialog and profile menu action handlers
|
||||
|
||||
## [2.1.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- Emoji picker and shortcode autocomplete in zap comment box
|
||||
- Zap button on badge detail view
|
||||
- Theme descriptions now display on "updated their theme" posts and detail pages
|
||||
- Badge thumbnail previews in award notifications
|
||||
- Letter notifications with envelope card preview
|
||||
- Kind-specific labels in notification text instead of generic "post"
|
||||
|
||||
### Fixed
|
||||
- Compose modal no longer closes when dismissing emoji picker on mobile
|
||||
- Compose preview overflow is now scrollable in modal
|
||||
- Toast notifications swipe up to dismiss on mobile instead of sideways
|
||||
- File downloads and URL opening work correctly on iOS
|
||||
- Badges page no longer shows infinite skeleton when logged out
|
||||
|
||||
## [2.1.0] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
|
||||
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
|
||||
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
|
||||
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
|
||||
- Letters page added to the sidebar with a custom mailbox icon
|
||||
|
||||
## [2.0.1] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Tap the version number in settings to see what's new
|
||||
|
||||
## [2.0.0] - 2026-03-26
|
||||
|
||||
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
|
||||
|
||||
@@ -2,12 +2,32 @@
|
||||
|
||||
## Event Kinds Overview
|
||||
|
||||
### Ditto Kinds
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------|-------------------------------------------------------|
|
||||
| 36767 | Theme Definition | Shareable, named custom UI theme |
|
||||
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
|
||||
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
|
||||
|
||||
### Community Kinds
|
||||
|
||||
These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors.
|
||||
|
||||
| Kind | Name | Description | Spec |
|
||||
|-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) |
|
||||
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
|
||||
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
|
||||
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
|
||||
| 11125 | Blobbonaut Profile | Owner profile with coins, achievements, and inventory | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14919 | Blobbi Interaction | Individual pet interaction (feed, play, clean, etc.) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14920 | Blobbi Breeding | Breeding event between two adult Blobbis | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14921 | Blobbi Record | Immutable lifecycle record (birth, evolution, adoption) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 16158 | Weather Station | Weather station metadata (location, sensors, connectivity) | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
|
||||
| 31124 | Blobbi Pet State | Current state of a virtual Blobbi pet (addressable) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
|
||||
|
||||
---
|
||||
|
||||
## Kind 36767: Theme Definition
|
||||
@@ -293,3 +313,51 @@ The `shape` field is added to the JSON content of a kind 0 event alongside stand
|
||||
- The `shape` field is purely cosmetic and has no protocol-level significance.
|
||||
- Clients MAY choose not to support this extension, in which case avatars render as circles as usual.
|
||||
|
||||
---
|
||||
|
||||
## Community NIP Specifications
|
||||
|
||||
The following specifications are maintained by their respective authors. Ditto implements these kinds but does not own the specs. See each link for the full event structure, tags, and client behavior.
|
||||
|
||||
### Color Moments (Kind 3367)
|
||||
|
||||
**Author:** Chad Curtis
|
||||
**Spec:** https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md
|
||||
**App:** https://espy.you
|
||||
|
||||
Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion.
|
||||
|
||||
### Geocaching (Kinds 37516, 7516)
|
||||
|
||||
**Author:** Chad Curtis
|
||||
**Spec:** https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md
|
||||
**App:** https://treasures.to
|
||||
|
||||
NIP-GC defines geocaching on Nostr. Kind 37516 (addressable) is a geocache listing with location (geohash), difficulty/terrain scores, size, and type. Kind 7516 is a found log recording a successful visit. The spec also covers comment logs (kind 1111 via NIP-22), verified finds with cryptographic proof (kind 7517), and cache retirement.
|
||||
|
||||
### Personal Letters (Kind 8211)
|
||||
|
||||
**Author:** Chad Curtis
|
||||
**Spec:** https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md
|
||||
**App:** https://lief.to
|
||||
|
||||
NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, decorative frames, and custom fonts. Letters render as 5:4 landscape postcards. The privacy model is intentionally postcard-like: sender/recipient metadata is visible, content is encrypted.
|
||||
|
||||
### Weather Station (Kinds 4223, 16158)
|
||||
|
||||
**Author:** Sam Thomson
|
||||
**Spec:** https://github.com/nostr-protocol/nips/pull/2163
|
||||
**App:** https://weather.shakespeare.wtf
|
||||
**Firmware:** https://github.com/samthomson/weather-station
|
||||
|
||||
Kind 16158 (replaceable) describes a weather station's configuration: name, geohash location, elevation, power source, connectivity, and sensor inventory. Kind 4223 (regular) carries individual sensor readings as 3-parameter tags `[sensor_type, value, model]`, enabling historical queries and cross-station comparison. Each station has its own keypair.
|
||||
|
||||
### Blobbi Virtual Pet (Kinds 31124, 14919, 14920, 14921, 11125)
|
||||
|
||||
**Author:** Danifra
|
||||
**Spec:** https://github.com/Danidfra/nostr-pet/blob/production/NIP.md
|
||||
**App:** https://nostr-pet.vercel.app
|
||||
**See also:** [Blobbi tag schema](docs/blobbi/blobbi-tag-schema.md) (Ditto-specific integration details)
|
||||
|
||||
NIP-BB defines a virtual pet lifecycle on Nostr. Kind 31124 (addressable) holds the current pet state across three stages (egg, baby, adult) with stats, appearance, and personality traits. Kind 14919 logs individual interactions, kind 14920 records breeding events, kind 14921 stores immutable lifecycle records, and kind 11125 (replaceable) holds the owner's profile with coins, achievements, and inventory.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Made by [Soapbox](https://soapbox.pub).
|
||||
## Features
|
||||
|
||||
- **Theming** -- 9 built-in theme presets, 19 CSS token properties for full customization, and the ability to publish and share themes as Nostr events
|
||||
- **Infinite Content Types** -- Text notes, articles, short-form videos (Vines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
|
||||
- **Infinite Content Types** -- Text notes, articles, short-form videos (Divines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
|
||||
- **Lightning Payments** -- Zap posts and profiles with sats via Nostr Wallet Connect (NWC) or WebLN
|
||||
- **Private Messaging** -- End-to-end encrypted DMs (NIP-04 and NIP-17)
|
||||
- **Comments** -- Comment on anything: posts, URLs, profiles, hashtags, books, and more (NIP-22)
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.0.0"
|
||||
versionName "2.3.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -35,8 +35,9 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
# Keep Capacitor classes (WebView JS bridge)
|
||||
-keep class com.getcapacitor.** { *; }
|
||||
-keep class pub.ditto.app.** { *; }
|
||||
|
||||
# Keep WebView JS interfaces
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
# Keep OkHttp (used by Capacitor)
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
|
||||
@@ -25,6 +25,8 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
public void configure(PluginCall call) {
|
||||
String userPubkey = call.getString("userPubkey");
|
||||
String relayUrlsRaw = null;
|
||||
String enabledKindsRaw = null;
|
||||
String authorsRaw = null;
|
||||
|
||||
try {
|
||||
JSONArray relayUrls = call.getArray("relayUrls");
|
||||
@@ -35,14 +37,40 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
Log.w(TAG, "Failed to read relayUrls", e);
|
||||
}
|
||||
|
||||
try {
|
||||
JSONArray enabledKinds = call.getArray("enabledKinds");
|
||||
if (enabledKinds != null) {
|
||||
enabledKindsRaw = enabledKinds.toString();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to read enabledKinds", e);
|
||||
}
|
||||
|
||||
try {
|
||||
JSONArray authors = call.getArray("authors");
|
||||
if (authors != null) {
|
||||
authorsRaw = authors.toString();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to read authors", e);
|
||||
}
|
||||
|
||||
SharedPreferences prefs = getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
|
||||
if (userPubkey != null && relayUrlsRaw != null) {
|
||||
prefs.edit()
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.putString("userPubkey", userPubkey)
|
||||
.putString("relayUrls", relayUrlsRaw)
|
||||
.apply();
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw);
|
||||
.putString("relayUrls", relayUrlsRaw);
|
||||
if (enabledKindsRaw != null) {
|
||||
editor.putString("enabledKinds", enabledKindsRaw);
|
||||
}
|
||||
if (authorsRaw != null) {
|
||||
editor.putString("authors", authorsRaw);
|
||||
} else {
|
||||
editor.remove("authors");
|
||||
}
|
||||
editor.apply();
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
|
||||
} else {
|
||||
// Clear config (user logged out)
|
||||
prefs.edit().clear().apply();
|
||||
|
||||
@@ -265,6 +265,7 @@ public class NostrPoller {
|
||||
}
|
||||
return "commented on your post";
|
||||
}
|
||||
case 8211: return "sent you a letter";
|
||||
default: return "mentioned you";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +243,8 @@ public class NotificationRelayService extends Service {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
String userPubkey = prefs.getString("userPubkey", null);
|
||||
String relayUrlsJson = prefs.getString("relayUrls", null);
|
||||
String enabledKindsJson = prefs.getString("enabledKinds", null);
|
||||
String authorsJson = prefs.getString("authors", null);
|
||||
|
||||
if (userPubkey == null || relayUrlsJson == null) {
|
||||
Log.d(TAG, "No config, skipping fetch");
|
||||
@@ -268,10 +270,17 @@ public class NotificationRelayService extends Service {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(relayUrls.get(relayIndex), userPubkey);
|
||||
List<Integer> enabledKinds = parseEnabledKinds(enabledKindsJson);
|
||||
if (enabledKinds.isEmpty()) {
|
||||
Log.d(TAG, "No enabled kinds, skipping fetch");
|
||||
releaseFetchWakeLock();
|
||||
return;
|
||||
}
|
||||
List<String> authors = parseAuthors(authorsJson);
|
||||
fetch(relayUrls.get(relayIndex), userPubkey, enabledKinds, authors);
|
||||
}
|
||||
|
||||
private void fetch(String relayUrl, String userPubkey) {
|
||||
private void fetch(String relayUrl, String userPubkey, List<Integer> enabledKinds, List<String> authors) {
|
||||
long since = poller.getLastSeenTimestamp();
|
||||
if (since == 0) {
|
||||
since = (System.currentTimeMillis() / 1000) - 300; // 5 min ago on first run
|
||||
@@ -284,7 +293,9 @@ public class NotificationRelayService extends Service {
|
||||
try {
|
||||
JSONObject filter = new JSONObject();
|
||||
JSONArray kinds = new JSONArray();
|
||||
kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111);
|
||||
for (int kind : enabledKinds) {
|
||||
kinds.put(kind);
|
||||
}
|
||||
filter.put("kinds", kinds);
|
||||
JSONArray pTags = new JSONArray();
|
||||
pTags.put(userPubkey);
|
||||
@@ -292,6 +303,15 @@ public class NotificationRelayService extends Service {
|
||||
filter.put("since", since + 1);
|
||||
filter.put("limit", FETCH_LIMIT);
|
||||
|
||||
// When "only from people I follow" is enabled, restrict to those authors
|
||||
if (!authors.isEmpty()) {
|
||||
JSONArray authorsArr = new JSONArray();
|
||||
for (String author : authors) {
|
||||
authorsArr.put(author);
|
||||
}
|
||||
filter.put("authors", authorsArr);
|
||||
}
|
||||
|
||||
JSONArray req = new JSONArray();
|
||||
req.put("REQ");
|
||||
req.put(currentSubId);
|
||||
@@ -397,7 +417,8 @@ public class NotificationRelayService extends Service {
|
||||
}
|
||||
|
||||
Log.d(TAG, "Retrying in " + backoffMs + "ms on relay " + relayIndex);
|
||||
Runnable retry = () -> fetch(relayUrls.get(relayIndex), userPubkey);
|
||||
// Re-read config from prefs on retry so enabled kinds stay current.
|
||||
Runnable retry = this::runFetchCycle;
|
||||
handler.postDelayed(retry, backoffMs);
|
||||
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
||||
|
||||
@@ -515,6 +536,46 @@ public class NotificationRelayService extends Service {
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the authors filter from JSON. Returns an empty list when the value
|
||||
* is null or invalid (meaning no author restriction).
|
||||
*/
|
||||
private List<String> parseAuthors(String json) {
|
||||
List<String> authors = new ArrayList<>();
|
||||
if (json != null) {
|
||||
try {
|
||||
JSONArray arr = new JSONArray(json);
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
authors.add(arr.getString(i));
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.w(TAG, "Failed to parse authors", e);
|
||||
}
|
||||
}
|
||||
return authors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the enabled notification kinds from JSON. Returns an empty list
|
||||
* when the value is null or invalid — the caller should skip polling
|
||||
* when the list is empty (the JS layer always provides kinds via
|
||||
* DittoNotification.configure in the same write as pubkey/relays).
|
||||
*/
|
||||
private List<Integer> parseEnabledKinds(String json) {
|
||||
List<Integer> kinds = new ArrayList<>();
|
||||
if (json != null) {
|
||||
try {
|
||||
JSONArray arr = new JSONArray(json);
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
kinds.add(arr.getInt(i));
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.w(TAG, "Failed to parse enabled kinds", e);
|
||||
}
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
private Notification buildForegroundNotification() {
|
||||
Intent notificationIntent = new Intent(this, MainActivity.class);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(
|
||||
|
||||
@@ -5,8 +5,14 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
@@ -60,7 +60,7 @@ const builtinThemes = {
|
||||
};
|
||||
```
|
||||
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
|
||||
### ThemeConfig
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
# Blobbi Tag Schema
|
||||
|
||||
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
|
||||
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
|
||||
|
||||
## Overview
|
||||
|
||||
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
|
||||
- All valid tags and their purposes
|
||||
- Which tags are required vs optional
|
||||
- Which tags persist across stage transitions
|
||||
- Which tags should be removed during transitions
|
||||
- Deprecated tags that should be filtered out
|
||||
|
||||
---
|
||||
|
||||
## Tag Categories
|
||||
|
||||
### 1. System / Metadata Tags
|
||||
|
||||
Core protocol-level tags required for event identification and ecosystem membership.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
|
||||
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
|
||||
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
|
||||
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
|
||||
|
||||
### 2. Core Identity Tags
|
||||
|
||||
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
|
||||
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
|
||||
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
|
||||
|
||||
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
|
||||
|
||||
### 3. Visual Trait Tags
|
||||
|
||||
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
|
||||
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
|
||||
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
|
||||
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
|
||||
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
|
||||
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
|
||||
|
||||
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
|
||||
|
||||
### 4. Personality / Trait Tags
|
||||
|
||||
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
|
||||
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
|
||||
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
|
||||
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
|
||||
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
|
||||
|
||||
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
|
||||
|
||||
### 5. Stat Tags
|
||||
|
||||
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
|
||||
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
|
||||
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
|
||||
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
|
||||
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
|
||||
|
||||
**Stage Transition Behavior**:
|
||||
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
|
||||
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
|
||||
|
||||
### 6. State / Lifecycle Tags
|
||||
|
||||
Tags that track the Blobbi's current lifecycle state.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
|
||||
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
|
||||
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
|
||||
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
|
||||
|
||||
**State Constraints**:
|
||||
- `incubating` is only valid for `stage: egg`
|
||||
- `evolving` is only valid for `stage: baby`
|
||||
- After hatch/evolve completes, `state` MUST be set to `active`
|
||||
|
||||
### 7. Task System Tags
|
||||
|
||||
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
|
||||
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
|
||||
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
|
||||
|
||||
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
|
||||
|
||||
### 8. Progression Tags
|
||||
|
||||
Long-term progress tracking that persists across all stages.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
|
||||
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
|
||||
|
||||
### 9. Social / Flag Tags
|
||||
|
||||
User preferences and computed flags.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
|
||||
|
||||
### 10. Evolution Tags
|
||||
|
||||
Tags specific to adult Blobbis.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
|
||||
|
||||
### 11. Extension Tags
|
||||
|
||||
Optional tags for themes and crossover features.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
|
||||
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
|
||||
|
||||
---
|
||||
|
||||
## Deprecated Tags
|
||||
|
||||
These tags are from legacy versions and MUST be removed when republishing events.
|
||||
|
||||
| Tag | Reason | Replaced By |
|
||||
|-----|--------|-------------|
|
||||
| `shell_integrity` | Eggs use standard `health` stat | `health` |
|
||||
| `egg_temperature` | Warmth handled via UI props | N/A |
|
||||
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
|
||||
| `egg_status` | Replaced by standard state | `state` |
|
||||
| `fees` | Removed | N/A |
|
||||
| `incubation_time` | Uses state_started_at | `state_started_at` |
|
||||
| `start_incubation` | Uses state_started_at | `state_started_at` |
|
||||
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
|
||||
|
||||
---
|
||||
|
||||
## Stage Transition Rules
|
||||
|
||||
### Hatch (egg → baby)
|
||||
|
||||
**Tags to REMOVE**:
|
||||
- `task`
|
||||
- `task_completed`
|
||||
- `state_started_at`
|
||||
|
||||
**Tags to UPDATE**:
|
||||
- `stage` → `baby`
|
||||
- `state` → `active`
|
||||
- `hunger` → `100`
|
||||
- `happiness` → `100`
|
||||
- `hygiene` → `100`
|
||||
- `energy` → `100`
|
||||
- `health` → (inherited from egg after decay)
|
||||
- `last_interaction` → current timestamp
|
||||
- `last_decay_at` → current timestamp
|
||||
|
||||
**Tags to PRESERVE (all persistent tags)**:
|
||||
- All system tags (`d`, `b`, `t`, `client`)
|
||||
- All identity tags (`name`, `seed`, `generation`)
|
||||
- All visual tags (colors, pattern, size)
|
||||
- All personality tags (if present)
|
||||
- All progression tags (`experience`, `care_streak`)
|
||||
- All social tags (`breeding_ready`)
|
||||
- All extension tags (`theme`, `crossover_app`)
|
||||
|
||||
### Evolve (baby → adult)
|
||||
|
||||
**Tags to REMOVE**:
|
||||
- `task`
|
||||
- `task_completed`
|
||||
- `state_started_at`
|
||||
|
||||
**Tags to UPDATE**:
|
||||
- `stage` → `adult`
|
||||
- `state` → `active`
|
||||
- All stats → (inherited from baby after decay)
|
||||
- `last_interaction` → current timestamp
|
||||
- `last_decay_at` → current timestamp
|
||||
|
||||
**Tags to PRESERVE (all persistent tags)**:
|
||||
- Same as hatch, plus all stats are inherited (not reset)
|
||||
|
||||
**Tags to ADD (optional)**:
|
||||
- `adult_type` → computed based on care history
|
||||
|
||||
---
|
||||
|
||||
## Migration Rules
|
||||
|
||||
When migrating legacy Blobbis to canonical format:
|
||||
|
||||
1. **Always preserve existing values** - Do not regenerate tags that already exist
|
||||
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
|
||||
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
|
||||
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
|
||||
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
A valid Blobbi event MUST have:
|
||||
- `d` tag in canonical format
|
||||
- `b` tag = `blobbi:ecosystem:v1`
|
||||
- `t` tag = `blobbi`
|
||||
- `name` tag (non-empty)
|
||||
- `seed` tag (64 hex chars)
|
||||
- `stage` tag (valid value)
|
||||
- `state` tag (valid value)
|
||||
- `last_interaction` tag (valid timestamp)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When implementing any flow that modifies Blobbi tags:
|
||||
|
||||
- [ ] Start from `canonical.allTags` as the base
|
||||
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
|
||||
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
|
||||
- [ ] Filter out deprecated tags
|
||||
- [ ] Update only the tags that need to change
|
||||
- [ ] Validate required tags are present
|
||||
@@ -39,6 +39,13 @@ export default tseslint.config(
|
||||
},
|
||||
],
|
||||
"custom/no-placeholder-comments": "error",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "CallExpression[callee.object.type='MetaProperty'][callee.property.name='glob']",
|
||||
"message": "import.meta.glob is Vite-only and breaks other bundlers. Inline the assets or use standard imports instead.",
|
||||
},
|
||||
],
|
||||
"no-warning-comments": [
|
||||
"error",
|
||||
{ terms: ["fixme"] },
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<meta name="twitter:description" content="Your content. Your vibe. Your rules." />
|
||||
<meta name="twitter:image" content="https://ditto.pub/og-image.jpg" />
|
||||
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#161b2e" media="(prefers-color-scheme: dark)">
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -325,7 +325,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 98 KiB |
@@ -47,5 +47,9 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Ditto needs access to your microphone to record voice messages.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<!-- Crash / performance data via Sentry/GlitchTip -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeCrashData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Performance / analytics data via Plausible -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePerformanceData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<!-- UserDefaults — used by Capacitor/WKWebView for localStorage -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<!-- CA92.1: Access info from same app -->
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- File timestamp APIs — used by @capacitor/filesystem -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<!-- C617.1: Access file timestamps inside app container -->
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Disk space APIs — used by WKWebView / file operations -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<!-- E174.1: Check available disk space before writing -->
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -13,7 +13,9 @@ let package = Package(
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
|
||||
],
|
||||
targets: [
|
||||
@@ -23,7 +25,9 @@ let package = Package(
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mkstack",
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"version": "2.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -11,13 +11,14 @@
|
||||
"icons": "bash scripts/generate-icons.sh"
|
||||
},
|
||||
"engines": {
|
||||
"npm": "10.9.4",
|
||||
"node": "22.x"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -26,6 +27,7 @@
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@fontsource-variable/comfortaa": "^5.2.8",
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/fredoka": "^5.2.10",
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lora": "^5.2.8",
|
||||
@@ -35,19 +37,35 @@
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource/bungee-shade": "^5.2.7",
|
||||
"@fontsource/caveat": "^5.2.8",
|
||||
"@fontsource/cherry-bomb-one": "^5.2.7",
|
||||
"@fontsource/comic-neue": "^5.2.7",
|
||||
"@fontsource/comic-relief": "^5.2.2",
|
||||
"@fontsource/courier-prime": "^5.2.8",
|
||||
"@fontsource/creepster": "^5.2.7",
|
||||
"@fontsource/luckiest-guy": "^5.2.8",
|
||||
"@fontsource/noto-sans-nushu": "^5.2.6",
|
||||
"@fontsource/pacifico": "^5.2.7",
|
||||
"@fontsource/permanent-marker": "^5.2.7",
|
||||
"@fontsource/pirata-one": "^5.2.8",
|
||||
"@fontsource/press-start-2p": "^5.2.7",
|
||||
"@fontsource/silkscreen": "^5.2.8",
|
||||
"@fontsource/special-elite": "^5.2.8",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@milkdown/core": "^7.20.0",
|
||||
"@milkdown/ctx": "^7.20.0",
|
||||
"@milkdown/plugin-clipboard": "^7.20.0",
|
||||
"@milkdown/plugin-history": "^7.20.0",
|
||||
"@milkdown/plugin-listener": "^7.20.0",
|
||||
"@milkdown/plugin-upload": "^7.20.0",
|
||||
"@milkdown/preset-commonmark": "^7.20.0",
|
||||
"@milkdown/preset-gfm": "^7.20.0",
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.0",
|
||||
"@nostrify/react": "^0.3.1",
|
||||
"@nostrify/react": "^0.4.0",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -56,18 +74,18 @@
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -76,7 +94,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.0.10",
|
||||
@@ -87,19 +105,21 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
"hls.js": "^1.6.15",
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-blurhash": "^0.3.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
@@ -108,11 +128,12 @@
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.12.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"slugify": "^1.6.8",
|
||||
"smol-toml": "^1.6.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uri-templates": "^0.2.0",
|
||||
"vaul": "^0.9.3",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -123,12 +144,14 @@
|
||||
"@html-eslint/eslint-plugin": "^0.41.0",
|
||||
"@html-eslint/parser": "^0.41.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"@webxdc/types": "^2.1.2",
|
||||
@@ -139,10 +162,15 @@
|
||||
"globals": "^15.9.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^8.0.0",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# Changelog
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
|
||||
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
|
||||
|
||||
### Changed
|
||||
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
|
||||
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
|
||||
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
|
||||
|
||||
### Fixed
|
||||
- Notification dot not clearing after marking notifications as read
|
||||
- Followers/following modal staying open after navigating to a profile
|
||||
|
||||
## [2.2.7] - 2026-03-31
|
||||
|
||||
### Fixed
|
||||
- Nushu script in encrypted letters now renders correctly on Android and iOS
|
||||
|
||||
## [2.2.6] - 2026-03-31
|
||||
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
- Badge detail page streamlined with unified tab bar
|
||||
|
||||
### Fixed
|
||||
- Hashtags now support accented and Unicode characters
|
||||
- Letter compose opens correctly from notifications and the letters page
|
||||
- Letter font picker loads fonts so each option previews in the correct typeface
|
||||
- Zap comment positioned inside the right column instead of floating with offset
|
||||
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
|
||||
|
||||
## [2.2.5] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Crash when dragging profile tabs to reorder them
|
||||
|
||||
## [2.2.4] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
|
||||
- Zap moved to the profile overflow menu so it's still one tap away
|
||||
|
||||
### Fixed
|
||||
- Crash on the notifications page caused by malformed badge award tags
|
||||
- Deleting a badge now also deletes all awards you issued for it
|
||||
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
|
||||
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
|
||||
- Profile reactions no longer collapse into a single grouped notification
|
||||
- Oversized reaction emoji in comment context headers
|
||||
|
||||
## [2.2.3] - 2026-03-30
|
||||
|
||||
### Added
|
||||
- Letters now have an overflow menu, reply button, and a grid layout for browsing
|
||||
- Independent feed toggles for comments and generic reposts in content settings
|
||||
- Sidebar items are now visible to logged-out users so newcomers can explore everything
|
||||
|
||||
### Changed
|
||||
- Compose textarea expands smoothly as you type instead of snapping to a new height
|
||||
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
|
||||
|
||||
### Fixed
|
||||
- Feed gaps when replies are disabled no longer cause missing posts
|
||||
- Avatar shape no longer flashes on load
|
||||
- Top bar arc no longer flickers during navigation transitions
|
||||
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
|
||||
- Notification rendering for badges and letters
|
||||
- Duplicate React keys in content settings
|
||||
- Layout rendering warning when switching views
|
||||
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
- Changelog link in the app footer
|
||||
|
||||
### Changed
|
||||
- "Vines" renamed to "Divines" everywhere in the app
|
||||
- Custom emojis appear first in the emoji picker, right after recent
|
||||
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
|
||||
|
||||
### Fixed
|
||||
- Delete post dialog no longer freezes the feed on desktop
|
||||
- Amber login on Android now properly retries when returning from the background
|
||||
- Key downloads on Android save to the correct location
|
||||
- Custom emoji SVGs render correctly in the emoji-mart picker
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
- Inconsistent use of "geocache" vs "treasures" terminology
|
||||
- Search page "N new posts" pill no longer shows unfiltered count
|
||||
- Stale-cache overwrites in replaceable event mutations
|
||||
- Click-through on delete confirmation and note menu items
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
|
||||
- Mobile header no longer shows double-layered backgrounds on notched devices
|
||||
- Pinned tabs stay properly positioned when scrolling on mobile
|
||||
- Signer approval toasts no longer fire in rapid succession on unstable connections
|
||||
- Toasts are easier to swipe away on mobile
|
||||
- Content warnings now blur thumbnails in the media grid
|
||||
|
||||
## [2.2.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
### Changed
|
||||
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
|
||||
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
|
||||
- Upgraded from React 18 to React 19
|
||||
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
|
||||
|
||||
### Fixed
|
||||
- Zapping Primal users no longer produces an error
|
||||
- Hashtag feeds now match case-insensitively for parity with search results
|
||||
- Mobile top bar arc no longer lingers on pages without tabs
|
||||
- Give Badge dialog and profile menu action handlers
|
||||
|
||||
## [2.1.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- Emoji picker and shortcode autocomplete in zap comment box
|
||||
- Zap button on badge detail view
|
||||
- Theme descriptions now display on "updated their theme" posts and detail pages
|
||||
- Badge thumbnail previews in award notifications
|
||||
- Letter notifications with envelope card preview
|
||||
- Kind-specific labels in notification text instead of generic "post"
|
||||
|
||||
### Fixed
|
||||
- Compose modal no longer closes when dismissing emoji picker on mobile
|
||||
- Compose preview overflow is now scrollable in modal
|
||||
- Toast notifications swipe up to dismiss on mobile instead of sideways
|
||||
- File downloads and URL opening work correctly on iOS
|
||||
- Badges page no longer shows infinite skeleton when logged out
|
||||
|
||||
## [2.1.0] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
|
||||
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
|
||||
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
|
||||
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
|
||||
- Letters page added to the sidebar with a custom mailbox icon
|
||||
|
||||
## [2.0.1] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Tap the version number in settings to see what's new
|
||||
|
||||
## [2.0.0] - 2026-03-26
|
||||
|
||||
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
|
||||
@@ -6,7 +6,7 @@ GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Generating Android app icons...${NC}\n"
|
||||
echo -e "${GREEN}Generating app icons...${NC}\n"
|
||||
|
||||
# Check for inkscape (preferred) or rsvg-convert as fallback
|
||||
if command -v inkscape &> /dev/null; then
|
||||
@@ -138,12 +138,33 @@ cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
|
||||
</resources>
|
||||
EOF
|
||||
|
||||
# ── iOS App Icon (1024x1024, white logo on purple background) ──
|
||||
|
||||
echo "Generating iOS app icon..."
|
||||
|
||||
IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
|
||||
|
||||
if [ -d "$IOS_ICON_DIR" ]; then
|
||||
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
|
||||
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
|
||||
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
|
||||
\( "$LOGO_WHITE" -resize "614x614" \) \
|
||||
-gravity center -compose over -composite \
|
||||
"$IOS_ICON"
|
||||
echo -e " ${GREEN}✓${NC} $IOS_ICON"
|
||||
else
|
||||
echo -e " ${YELLOW}Skipped: $IOS_ICON_DIR not found${NC}"
|
||||
fi
|
||||
|
||||
# Cleanup temp files
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
echo -e "\n${GREEN}Android icons generated successfully!${NC}"
|
||||
echo -e "\n${GREEN}App icons generated successfully!${NC}"
|
||||
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
|
||||
echo -e "Generated:"
|
||||
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
|
||||
echo -e " - ic_launcher.png (legacy square, all densities)"
|
||||
echo -e " - ic_launcher_round.png (legacy round, all densities)"
|
||||
echo -e " Android:"
|
||||
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
|
||||
echo -e " - ic_launcher.png (legacy square, all densities)"
|
||||
echo -e " - ic_launcher_round.png (legacy round, all densities)"
|
||||
echo -e " iOS:"
|
||||
echo -e " - AppIcon-512@2x.png (1024x1024)"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* NIP-46 Client-Initiated Auth Script
|
||||
*
|
||||
* Generates an ephemeral client keypair and a `nostrconnect://` URI.
|
||||
* Import the URI into a remote signer app (e.g. Amber) to authorize
|
||||
* the client key. Once authorized, the script outputs:
|
||||
*
|
||||
* - bunker:// URI (for ZAPSTORE_BUNKER_URL)
|
||||
* - client secret key hex (for ZAPSTORE_CLIENT_KEY)
|
||||
*
|
||||
* It also writes the client key to ~/.config/zsp/bunker-keys/<bunkerPubkey>.key
|
||||
* so that `zsp` can use it immediately.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/nip46-auth.mjs [--relay wss://relay.example.com] [--name MyApp] [--timeout 300]
|
||||
*/
|
||||
|
||||
import { NPool, NRelay1, NConnectSigner, NSecSigner } from '@nostrify/nostrify';
|
||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { bytesToHex } from '@noble/hashes/utils';
|
||||
import QRCode from 'qrcode';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI args
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const result = {
|
||||
relays: [],
|
||||
name: 'Ditto',
|
||||
timeout: 300, // seconds
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--relay':
|
||||
result.relays.push(args[++i]);
|
||||
break;
|
||||
case '--name':
|
||||
result.name = args[++i];
|
||||
break;
|
||||
case '--timeout':
|
||||
result.timeout = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
console.log(`Usage: node scripts/nip46-auth.mjs [options]
|
||||
|
||||
Options:
|
||||
--relay <url> Relay URL for NIP-46 communication (repeatable)
|
||||
Default: wss://relay.ditto.pub
|
||||
--name <name> Application name shown to the signer
|
||||
Default: Ditto
|
||||
--timeout <sec> How long to wait for signer approval (seconds)
|
||||
Default: 300 (5 minutes)
|
||||
--help, -h Show this help message
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.relays.length === 0) {
|
||||
result.relays.push('wss://relay.ditto.pub');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
|
||||
// 1. Generate ephemeral client keypair
|
||||
const clientSecretKey = generateSecretKey();
|
||||
const clientPubkey = getPublicKey(clientSecretKey);
|
||||
const clientHex = bytesToHex(clientSecretKey);
|
||||
|
||||
console.log('');
|
||||
console.log('=== NIP-46 Client-Initiated Auth ===');
|
||||
console.log('');
|
||||
console.log(`Client pubkey: ${clientPubkey}`);
|
||||
console.log(`Relay(s): ${opts.relays.join(', ')}`);
|
||||
console.log(`Timeout: ${opts.timeout}s`);
|
||||
console.log('');
|
||||
|
||||
// 2. Generate random secret
|
||||
const randomBytes = new Uint8Array(4);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
const secret = Array.from(randomBytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
// 3. Build nostrconnect:// URI
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const relay of opts.relays) {
|
||||
searchParams.append('relay', relay);
|
||||
}
|
||||
searchParams.set('secret', secret);
|
||||
searchParams.set('name', opts.name);
|
||||
|
||||
const nostrConnectURI = `nostrconnect://${clientPubkey}?${searchParams.toString()}`;
|
||||
|
||||
console.log('Scan this QR code with your signer app (e.g. Amber):');
|
||||
console.log('');
|
||||
console.log(await QRCode.toString(nostrConnectURI, { type: 'terminal', small: true }));
|
||||
console.log('Or import this URI manually:');
|
||||
console.log('');
|
||||
console.log(` ${nostrConnectURI}`);
|
||||
console.log('');
|
||||
console.log('Waiting for signer to approve the connection...');
|
||||
console.log('');
|
||||
|
||||
// 4. Set up relay pool
|
||||
const pool = new NPool({
|
||||
open: (url) => new NRelay1(url),
|
||||
reqRouter: async (filters) => new Map(opts.relays.map((r) => [r, filters])),
|
||||
eventRouter: async () => opts.relays,
|
||||
});
|
||||
|
||||
const clientSigner = new NSecSigner(clientSecretKey);
|
||||
const relayGroup = pool.group(opts.relays);
|
||||
|
||||
// 5. Subscribe and wait for the signer's response
|
||||
const signal = AbortSignal.timeout(opts.timeout * 1000);
|
||||
|
||||
const sub = relayGroup.req(
|
||||
[{ kinds: [24133], '#p': [clientPubkey], limit: 1 }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
let bunkerPubkey;
|
||||
let userPubkey;
|
||||
|
||||
try {
|
||||
for await (const msg of sub) {
|
||||
if (msg[0] === 'CLOSED') {
|
||||
throw new Error('Relay closed the subscription before signer responded');
|
||||
}
|
||||
if (msg[0] === 'EVENT') {
|
||||
const event = msg[2];
|
||||
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = await clientSigner.nip44.decrypt(event.pubkey, event.content);
|
||||
} catch {
|
||||
// Could not decrypt -- not for us, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(decrypted);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.result !== secret && response.result !== 'ack') {
|
||||
continue;
|
||||
}
|
||||
|
||||
bunkerPubkey = event.pubkey;
|
||||
|
||||
console.log(`Signer responded! Bunker pubkey: ${bunkerPubkey}`);
|
||||
console.log('');
|
||||
|
||||
// 6. Get user pubkey via the now-established connection
|
||||
const signer = new NConnectSigner({
|
||||
relay: relayGroup,
|
||||
pubkey: bunkerPubkey,
|
||||
signer: clientSigner,
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
console.log('Requesting user public key...');
|
||||
userPubkey = await signer.getPublicKey();
|
||||
console.log(`User pubkey: ${userPubkey}`);
|
||||
console.log('');
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
console.error(`Timed out after ${opts.timeout}s waiting for signer approval.`);
|
||||
console.error('Make sure you imported the nostrconnect:// URI into your signer app.');
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!bunkerPubkey || !userPubkey) {
|
||||
console.error('Failed to establish connection with remote signer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 7. Build bunker:// URI (for CI)
|
||||
const bunkerParams = new URLSearchParams();
|
||||
for (const relay of opts.relays) {
|
||||
bunkerParams.append('relay', relay);
|
||||
}
|
||||
const bunkerURI = `bunker://${bunkerPubkey}?${bunkerParams.toString()}`;
|
||||
|
||||
// 8. Write client key to zsp config
|
||||
const zspDir = path.join(os.homedir(), '.config', 'zsp', 'bunker-keys');
|
||||
const zspKeyFile = path.join(zspDir, `${bunkerPubkey}.key`);
|
||||
|
||||
fs.mkdirSync(zspDir, { recursive: true });
|
||||
fs.writeFileSync(zspKeyFile, clientHex + '\n', { mode: 0o600 });
|
||||
|
||||
// 9. Print results
|
||||
console.log('=== Connection Established ===');
|
||||
console.log('');
|
||||
console.log('Bunker URI (ZAPSTORE_BUNKER_URL):');
|
||||
console.log(` ${bunkerURI}`);
|
||||
console.log('');
|
||||
console.log('Client secret key hex (ZAPSTORE_CLIENT_KEY):');
|
||||
console.log(` ${clientHex}`);
|
||||
console.log('');
|
||||
console.log(`User pubkey: ${userPubkey}`);
|
||||
console.log(`User npub: ${nip19.npubEncode(userPubkey)}`);
|
||||
console.log('');
|
||||
console.log(`zsp client key written to: ${zspKeyFile}`);
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Update ZAPSTORE_BUNKER_URL in GitLab CI/CD variables');
|
||||
console.log(' 2. Update ZAPSTORE_CLIENT_KEY in GitLab CI/CD variables');
|
||||
console.log('');
|
||||
|
||||
// Clean up
|
||||
pool.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -16,15 +16,19 @@ import NostrProvider from "@/components/NostrProvider";
|
||||
import { NostrSync } from "@/components/NostrSync";
|
||||
import { PlausibleProvider } from "@/components/PlausibleProvider";
|
||||
import { SentryProvider } from "@/components/SentryProvider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
const dmConfig: DMConfig = {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
|
||||
};
|
||||
|
||||
@@ -57,7 +61,9 @@ const hardcodedConfig: AppConfig = {
|
||||
},
|
||||
feedSettings: {
|
||||
feedIncludePosts: true,
|
||||
feedIncludeComments: true,
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showEvents: true,
|
||||
@@ -110,17 +116,17 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeBadgeDefinitions: true,
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeVanish: true,
|
||||
feedIncludeBlobbi: true,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
"feed",
|
||||
"notifications",
|
||||
"search",
|
||||
"bookmarks",
|
||||
"profile",
|
||||
"photos",
|
||||
"videos",
|
||||
"themes",
|
||||
"letters",
|
||||
"badges",
|
||||
"blobbi",
|
||||
"theme",
|
||||
"settings",
|
||||
"help",
|
||||
@@ -144,18 +150,35 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse and validate build-time ditto.json overrides from the env string.
|
||||
* Returns an empty object when no config file was provided or validation fails.
|
||||
*/
|
||||
function parseDittoConfig(): DittoConfig {
|
||||
try {
|
||||
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
|
||||
if (!json) return {};
|
||||
return DittoConfigSchema.parse(json);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge hardcoded defaults with build-time ditto.json overrides.
|
||||
* Deep-merges feedSettings so a partial override doesn't erase defaults.
|
||||
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
|
||||
*/
|
||||
const dittoConfig = parseDittoConfig();
|
||||
const defaultConfig: AppConfig = {
|
||||
...hardcodedConfig,
|
||||
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
|
||||
? __DITTO_CONFIG__
|
||||
: {}),
|
||||
...dittoConfig,
|
||||
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
|
||||
};
|
||||
|
||||
export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize StatusBar for mobile apps
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
@@ -178,14 +201,16 @@ export function App() {
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<DMProvider config={dmConfig}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
</TooltipProvider>
|
||||
<EmotionDevProvider>
|
||||
<TooltipProvider>
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
</TooltipProvider>
|
||||
</EmotionDevProvider>
|
||||
</DMProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
|
||||
@@ -1,59 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
|
||||
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
|
||||
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { ReplyComposeModal } from "@/components/ReplyComposeModal";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { getExtraKindDef } from "./lib/extraKinds";
|
||||
import { AdvancedSettingsPage } from "./pages/AdvancedSettingsPage";
|
||||
import { AIChatPage } from "./pages/AIChatPage";
|
||||
import { BadgesPage } from "./pages/BadgesPage";
|
||||
import { BookmarksPage } from "./pages/BookmarksPage";
|
||||
import { BooksPage } from "./pages/BooksPage";
|
||||
import { ContentPage } from "./pages/ContentPage";
|
||||
import { ContentSettingsPage } from "./pages/ContentSettingsPage";
|
||||
import { DomainFeedPage } from "./pages/DomainFeedPage";
|
||||
import { EventsFeedPage } from "./pages/EventsFeedPage";
|
||||
import { ExternalContentPage } from "./pages/ExternalContentPage";
|
||||
import { GeotagPage } from "./pages/GeotagPage";
|
||||
import { HashtagPage } from "./pages/HashtagPage";
|
||||
import { HelpPage } from "./pages/HelpPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
|
||||
// Critical-path pages: eagerly loaded (landing + fallback)
|
||||
import Index from "./pages/Index";
|
||||
import { KindFeedPage } from "./pages/KindFeedPage";
|
||||
import { MagicSettingsPage } from "./pages/MagicSettingsPage";
|
||||
import { MusicFeedPage } from "./pages/MusicFeedPage";
|
||||
import { NetworkSettingsPage } from "./pages/NetworkSettingsPage";
|
||||
import { NIP19Page } from "./pages/NIP19Page";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import { NotificationSettings } from "./pages/NotificationSettings";
|
||||
import { NotificationsPage } from "./pages/NotificationsPage";
|
||||
import { PhotosFeedPage } from "./pages/PhotosFeedPage";
|
||||
import { PodcastsFeedPage } from "./pages/PodcastsFeedPage";
|
||||
import { CSAEPolicyPage } from "./pages/CSAEPolicyPage";
|
||||
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
|
||||
import { ProfileSettings } from "./pages/ProfileSettings";
|
||||
import { RelayPage } from "./pages/RelayPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { ThemesPage } from "./pages/ThemesPage";
|
||||
import { TreasuresPage } from "./pages/TreasuresPage";
|
||||
import { TrendsPage } from "./pages/TrendsPage";
|
||||
import { UserListsPage } from "./pages/UserListsPage";
|
||||
import { VideosFeedPage } from "./pages/VideosFeedPage";
|
||||
import { VinesFeedPage } from "./pages/VinesFeedPage";
|
||||
import { WalletSettingsPage } from "./pages/WalletSettingsPage";
|
||||
import { WebxdcFeedPage } from "./pages/WebxdcFeedPage";
|
||||
import { WorldPage } from "./pages/WorldPage";
|
||||
import { ArchivePage } from "./pages/ArchivePage";
|
||||
import { BlueskyPage } from "./pages/BlueskyPage";
|
||||
import { WikipediaPage } from "./pages/WikipediaPage";
|
||||
|
||||
// Lazy-loaded companion layer (~450K code-split)
|
||||
const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => ({ default: m.BlobbiCompanionLayer })));
|
||||
|
||||
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
|
||||
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
|
||||
|
||||
// Lazy-loaded emoji pack dialog
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
|
||||
// All other pages: code-split via React.lazy
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
|
||||
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
|
||||
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
|
||||
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
|
||||
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
|
||||
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
|
||||
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
|
||||
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
|
||||
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
|
||||
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
|
||||
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
|
||||
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
|
||||
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
|
||||
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
|
||||
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
|
||||
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
|
||||
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
|
||||
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
|
||||
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
|
||||
const MusicFeedPage = lazy(() => import("./pages/MusicFeedPage").then(m => ({ default: m.MusicFeedPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
|
||||
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
|
||||
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
|
||||
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
|
||||
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
|
||||
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
|
||||
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
|
||||
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
|
||||
const ThemesPage = lazy(() => import("./pages/ThemesPage").then(m => ({ default: m.ThemesPage })));
|
||||
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
|
||||
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
|
||||
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
|
||||
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
|
||||
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
const colorsDef = getExtraKindDef("colors")!;
|
||||
@@ -74,7 +98,31 @@ function PollsFeedPage() {
|
||||
icon={sidebarItemIcon("polls", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
|
||||
{composeOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
|
||||
function EmojiFeedPage() {
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<KindFeedPage
|
||||
kind={emojisDef.kind}
|
||||
title={emojisDef.label}
|
||||
icon={sidebarItemIcon("emojis", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
{composeOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -91,10 +139,17 @@ export function AppRouter() {
|
||||
return (
|
||||
<AudioPlayerProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<MinimizedAudioBar />
|
||||
<AudioNavigationGuard />
|
||||
<DeepLinkHandler />
|
||||
<ScrollToTop />
|
||||
<BlobbiActionsProvider>
|
||||
<Suspense fallback={null}>
|
||||
<BlobbiCompanionLayer />
|
||||
</Suspense>
|
||||
</BlobbiActionsProvider>
|
||||
<Routes>
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
@@ -157,6 +212,8 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route path="/webxdc" element={<WebxdcFeedPage />} />
|
||||
<Route path="/articles/new" element={<ArticleEditorPage />} />
|
||||
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
|
||||
<Route
|
||||
path="/articles"
|
||||
element={
|
||||
@@ -164,6 +221,7 @@ export function AppRouter() {
|
||||
kind={articlesDef.kind}
|
||||
title={articlesDef.label}
|
||||
icon={sidebarItemIcon("articles", "size-5")}
|
||||
fabHref="/articles/new"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -177,16 +235,7 @@ export function AppRouter() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/emojis"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={emojisDef.kind}
|
||||
title={emojisDef.label}
|
||||
icon={sidebarItemIcon("emojis", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/emojis" element={<EmojiFeedPage />} />
|
||||
<Route
|
||||
path="/development"
|
||||
element={
|
||||
@@ -204,15 +253,20 @@ export function AppRouter() {
|
||||
<Route path="/themes" element={<ThemesPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/blobbi" element={<BlobbiPage />} />
|
||||
<Route path="/world" element={<WorldPage />} />
|
||||
<Route path="/badges" element={<BadgesPage />} />
|
||||
<Route path="/books" element={<BooksPage />} />
|
||||
<Route path="/archive" element={<ArchivePage />} />
|
||||
<Route path="/bluesky" element={<BlueskyPage />} />
|
||||
<Route path="/wikipedia" element={<WikipediaPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
<Route path="/r/*" element={<RelayPage />} />
|
||||
<Route
|
||||
path="/settings/lists"
|
||||
@@ -220,6 +274,8 @@ export function AppRouter() {
|
||||
/>
|
||||
<Route path="/i/*" element={<ExternalContentPage />} />
|
||||
|
||||
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
|
||||
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||
<Route path="/:nip19" element={<NIP19Page />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
filterInventoryByAction,
|
||||
previewStatChanges,
|
||||
previewMedicineForEgg,
|
||||
previewCleanForEgg,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
ACTION_METADATA,
|
||||
type InventoryAction,
|
||||
type ResolvedInventoryItem,
|
||||
type EggStatPreview,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
|
||||
interface BlobbiActionInventoryModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
action: InventoryAction;
|
||||
companion: BlobbiCompanion;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called when user confirms using item(s). Now accepts quantity. */
|
||||
onUseItem: (itemId: string, quantity: number) => void;
|
||||
onOpenShop: () => void;
|
||||
isUsingItem: boolean;
|
||||
usingItemId: string | null;
|
||||
}
|
||||
|
||||
export function BlobbiActionInventoryModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
action,
|
||||
companion,
|
||||
profile,
|
||||
onUseItem,
|
||||
onOpenShop,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
}: BlobbiActionInventoryModalProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
|
||||
// State for confirmation dialog
|
||||
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
// Filter inventory by action type, respecting egg-compatible effects
|
||||
const availableItems = useMemo(() => {
|
||||
if (!profile) return [];
|
||||
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
|
||||
}, [profile, action, companion.stage]);
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
const canUse = canUseAction(companion, action);
|
||||
const stageMessage = getStageRestrictionMessage(companion, action);
|
||||
|
||||
const isEmpty = availableItems.length === 0;
|
||||
|
||||
const handleSelectItem = (item: ResolvedInventoryItem) => {
|
||||
if (isUsingItem) return;
|
||||
setSelectedItem(item);
|
||||
setQuantity(1);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmUse = () => {
|
||||
if (!selectedItem || isUsingItem) return;
|
||||
onUseItem(selectedItem.itemId, quantity);
|
||||
// Reset after starting use
|
||||
setShowConfirmDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
};
|
||||
|
||||
const handleCloseConfirmDialog = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setShowConfirmDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShop = () => {
|
||||
onOpenChange(false);
|
||||
onOpenShop();
|
||||
};
|
||||
|
||||
// Quantity controls
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
setQuantity(1);
|
||||
} else {
|
||||
setQuantity(Math.min(value, maxQuantity));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xl sm:text-2xl shrink-0">
|
||||
{actionMeta.icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-lg sm:text-xl">{actionMeta.label}</DialogTitle>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate">
|
||||
{actionMeta.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
{/* Stage Restriction Message */}
|
||||
{!canUse && stageMessage && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="size-16 rounded-2xl bg-amber-500/10 flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">🥚</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Not Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
{stageMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{canUse && isEmpty && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">{actionMeta.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-4">
|
||||
You don't have any items for this action. Visit the shop to get some!
|
||||
</p>
|
||||
<Button onClick={handleOpenShop} className="gap-2">
|
||||
<ShoppingBag className="size-4" />
|
||||
Open Shop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item List */}
|
||||
{canUse && !isEmpty && (
|
||||
<div className="grid gap-3">
|
||||
{availableItems.map((item) => (
|
||||
<BlobbiInventoryUseRow
|
||||
key={item.itemId}
|
||||
item={item}
|
||||
companion={companion}
|
||||
action={action}
|
||||
onUse={() => handleSelectItem(item)}
|
||||
isUsing={isUsingItem && usingItemId === item.itemId}
|
||||
disabled={isUsingItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Confirmation Dialog with Quantity Selector */}
|
||||
{selectedItem && (
|
||||
<BlobbiUseItemConfirmDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={handleCloseConfirmDialog}
|
||||
item={selectedItem}
|
||||
companion={companion}
|
||||
action={action}
|
||||
quantity={quantity}
|
||||
maxQuantity={maxQuantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
onQuantityChange={handleQuantityInput}
|
||||
onConfirm={handleConfirmUse}
|
||||
isUsing={isUsingItem}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inventory Use Row ────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiInventoryUseRowProps {
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
action: InventoryAction;
|
||||
onUse: () => void;
|
||||
isUsing: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function BlobbiInventoryUseRow({
|
||||
item,
|
||||
companion,
|
||||
action,
|
||||
onUse,
|
||||
isUsing,
|
||||
disabled,
|
||||
}: BlobbiInventoryUseRowProps) {
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// Preview stat changes - handle egg-specific preview for medicine and clean
|
||||
const { normalStatChanges, eggStatChanges } = useMemo(() => {
|
||||
if (isEgg && isMedicine) {
|
||||
// For eggs using medicine, show health preview
|
||||
// Eggs use the 3-stat model: health, hygiene, happiness
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
|
||||
};
|
||||
}
|
||||
if (isEgg && isClean) {
|
||||
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewCleanForEgg(
|
||||
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
|
||||
item.effect
|
||||
),
|
||||
};
|
||||
}
|
||||
// Normal stats preview
|
||||
return {
|
||||
normalStatChanges: previewStatChanges(companion.stats, item.effect),
|
||||
eggStatChanges: [] as EggStatPreview[],
|
||||
};
|
||||
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
|
||||
|
||||
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm hover:border-primary/30 transition-colors">
|
||||
{/* Top row on mobile: Icon + Info + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className="relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl">
|
||||
{item.icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
x{item.quantity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Effect Preview - shown inline on desktop */}
|
||||
<div className="hidden sm:block">
|
||||
{hasChanges && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{/* Normal stat changes */}
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onUse}
|
||||
disabled={disabled}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isUsing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
'Use'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Effect Preview - shown below on mobile */}
|
||||
{hasChanges && (
|
||||
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
|
||||
{/* Normal stat changes */}
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
|
||||
|
||||
interface BlobbiUseItemConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onConfirm: () => void;
|
||||
isUsing: boolean;
|
||||
}
|
||||
|
||||
function BlobbiUseItemConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
companion,
|
||||
action,
|
||||
quantity,
|
||||
maxQuantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
onQuantityChange,
|
||||
onConfirm,
|
||||
isUsing,
|
||||
}: BlobbiUseItemConfirmDialogProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// Preview stat changes for the selected quantity
|
||||
const statPreview = useMemo(() => {
|
||||
if (!item.effect) return { normalChanges: [], eggChanges: [] };
|
||||
|
||||
if (isEgg && isMedicine) {
|
||||
// Calculate health change for N items
|
||||
const healthDelta = item.effect.health ?? 0;
|
||||
let currentHealth = companion.stats.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
|
||||
}
|
||||
const totalDelta = currentHealth - (companion.stats.health ?? 0);
|
||||
return {
|
||||
normalChanges: [],
|
||||
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (isEgg && isClean) {
|
||||
// Calculate hygiene and happiness changes for N items
|
||||
const hygieneDelta = item.effect.hygiene ?? 0;
|
||||
const happinessDelta = item.effect.happiness ?? 0;
|
||||
let currentHygiene = companion.stats.hygiene ?? 0;
|
||||
let currentHappiness = companion.stats.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
|
||||
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
|
||||
}
|
||||
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
|
||||
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
|
||||
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
|
||||
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
|
||||
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
|
||||
return { normalChanges: [], eggChanges: changes };
|
||||
}
|
||||
|
||||
// Normal stats preview - simulate N applications
|
||||
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
|
||||
const currentStats = { ...companion.stats };
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
for (const stat of statKeys) {
|
||||
const delta = item.effect[stat];
|
||||
if (delta !== undefined) {
|
||||
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
const changes: Array<{ stat: string; delta: number }> = [];
|
||||
for (const stat of statKeys) {
|
||||
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
|
||||
if (delta !== 0) {
|
||||
changes.push({ stat, delta });
|
||||
}
|
||||
}
|
||||
return { normalChanges: changes, eggChanges: [] };
|
||||
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
|
||||
|
||||
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionMeta.label}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Item Preview */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.quantity} in inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Quantity</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {maxQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onDecrease}
|
||||
disabled={quantity <= 1 || isUsing}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQuantity}
|
||||
value={quantity}
|
||||
onChange={onQuantityChange}
|
||||
disabled={isUsing}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onIncrease}
|
||||
disabled={quantity >= maxQuantity || isUsing}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Effects Summary */}
|
||||
{hasChanges && (
|
||||
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
|
||||
<h4 className="text-sm font-medium mb-2">
|
||||
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statPreview.normalChanges.map(({ stat, delta }) => (
|
||||
<Badge
|
||||
key={stat}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
delta > 0
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta} {stat}
|
||||
</Badge>
|
||||
))}
|
||||
{statPreview.eggChanges.map(({ stat, delta }) => (
|
||||
<Badge
|
||||
key={stat}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
delta > 0
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta} {stat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUsing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isUsing}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isUsing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Using...
|
||||
</>
|
||||
) : (
|
||||
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// src/blobbi/actions/components/BlobbiActionsModal.tsx
|
||||
|
||||
import { Loader2, Moon, Sun, Utensils, Gamepad2, Sparkles as SparklesIcon, Pill, Music, Mic, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
|
||||
|
||||
interface BlobbiActionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companion: BlobbiCompanion;
|
||||
onRest: () => void;
|
||||
onInventoryAction: (action: InventoryAction) => void;
|
||||
onDirectAction: (action: DirectAction) => void;
|
||||
actionInProgress: string | null;
|
||||
isPublishing: boolean;
|
||||
}
|
||||
|
||||
export function BlobbiActionsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
onRest,
|
||||
onInventoryAction,
|
||||
onDirectAction,
|
||||
actionInProgress,
|
||||
isPublishing,
|
||||
}: BlobbiActionsModalProps) {
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
const isDisabled = isPublishing || actionInProgress !== null;
|
||||
const isEgg = companion.stage === 'egg';
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle>Blobbi Actions</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">{companion.name}</p>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="grid gap-3">
|
||||
{/* Feed Action - hidden for eggs */}
|
||||
{!isEgg && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('feed'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Utensils className="size-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Feed</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Give your Blobbi something to eat
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Play Action - hidden for eggs */}
|
||||
{!isEgg && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('play'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Gamepad2 className="size-5 text-yellow-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Play</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Play with toys to make your Blobbi happy
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Clean Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('clean'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SparklesIcon className="size-5 text-blue-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Clean</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Keep your egg clean and fresh'
|
||||
: 'Keep your Blobbi clean and fresh'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Medicine Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('medicine'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Pill className="size-5 text-green-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Medicine</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Keep your egg healthy'
|
||||
: 'Heal your Blobbi'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Play Music Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onDirectAction('play_music'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Music className="size-5 text-pink-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Play Music</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Play soothing music for your egg'
|
||||
: 'Play music for your Blobbi'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Sing Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onDirectAction('sing'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Mic className="size-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Sing</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Sing a lullaby to your egg'
|
||||
: 'Sing to your Blobbi'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Sleep/Wake Action - hidden for eggs */}
|
||||
{!isEgg && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(onRest)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{actionInProgress === 'rest' ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : isSleeping ? (
|
||||
<Sun className="size-5 text-amber-500" />
|
||||
) : (
|
||||
<Moon className="size-5 text-violet-500" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{isSleeping ? 'Wake Up' : 'Sleep'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSleeping ? 'Wake your Blobbi up' : 'Put your Blobbi to sleep'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
|
||||
|
||||
/**
|
||||
* Missions modal for Blobbi.
|
||||
*
|
||||
* Shows:
|
||||
* - Daily missions (always visible, separate reward system)
|
||||
* - Incubation tasks when the current Blobbi is incubating (egg stage)
|
||||
* - Evolve tasks when evolving (baby stage)
|
||||
*/
|
||||
|
||||
import { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react';
|
||||
import { formatCompactNumber, cn } from '@/lib/utils';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { HatchTasksResult } from '../hooks/useHatchTasks';
|
||||
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
|
||||
import { TasksPanel } from './TasksPanel';
|
||||
import { DailyMissionsPanel } from './DailyMissionsPanel';
|
||||
import { useDailyMissions } from '../hooks/useDailyMissions';
|
||||
import { useClaimMissionReward } from '../hooks/useClaimMissionReward';
|
||||
import { useRerollMission } from '../hooks/useRerollMission';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiMissionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Current companion being viewed */
|
||||
companion: BlobbiCompanion;
|
||||
/** Current Blobbonaut profile (required for coin updates) */
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Callback to update profile in query cache after claiming */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Hatch tasks result from useHatchTasks */
|
||||
hatchTasks: HatchTasksResult;
|
||||
/** Evolve tasks result from useEvolveTasks */
|
||||
evolveTasks: EvolveTasksResult;
|
||||
/** Called when user clicks "Create Post" action in tasks */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all hatch tasks are complete and user clicks "Hatch" */
|
||||
onHatch: () => void;
|
||||
/** Whether hatching is in progress */
|
||||
isHatching: boolean;
|
||||
/** Called when all evolve tasks are complete and user clicks "Evolve" */
|
||||
onEvolve: () => void;
|
||||
/** Whether evolving is in progress */
|
||||
isEvolving: boolean;
|
||||
/** Called when user confirms stopping incubation */
|
||||
onStopIncubation: () => Promise<void>;
|
||||
/** Whether stop incubation is in progress */
|
||||
isStoppingIncubation: boolean;
|
||||
/** Called when user confirms stopping evolution */
|
||||
onStopEvolution: () => Promise<void>;
|
||||
/** Whether stop evolution is in progress */
|
||||
isStoppingEvolution: boolean;
|
||||
/** Available Blobbi stages across all user's companions (for mission filtering) */
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
}
|
||||
|
||||
// ─── Daily Missions Section ───────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsSectionProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Available Blobbi stages the user has */
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
disabled?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function DailyMissionsSection({ profile, updateProfileEvent, availableStages, disabled, defaultOpen = true }: DailyMissionsSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const {
|
||||
missions,
|
||||
todayClaimedReward,
|
||||
totalPotentialReward,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
} = useDailyMissions({ availableStages });
|
||||
|
||||
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
|
||||
profile,
|
||||
updateProfileEvent
|
||||
);
|
||||
|
||||
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
|
||||
|
||||
const handleClaimReward = (missionId: string) => {
|
||||
claimReward({ missionId });
|
||||
};
|
||||
|
||||
const handleRerollMission = (missionId: string) => {
|
||||
rerollMission({ missionId, availableStages });
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||
{/* Section header - Clickable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="size-4 text-primary shrink-0" />
|
||||
<h3 className="font-semibold text-sm">Daily Missions</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Coins className="size-3 shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className={cn(
|
||||
"size-4 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Mission list */}
|
||||
<CollapsibleContent className="pt-3">
|
||||
<DailyMissionsPanel
|
||||
missions={missions}
|
||||
onClaimReward={handleClaimReward}
|
||||
onRerollMission={handleRerollMission}
|
||||
todayCoins={todayClaimedReward}
|
||||
disabled={disabled || isClaiming || isRerolling}
|
||||
bonusAvailable={bonusAvailable}
|
||||
bonusClaimed={bonusClaimed}
|
||||
bonusReward={bonusReward}
|
||||
noMissionsAvailable={noMissionsAvailable}
|
||||
rerollsRemaining={rerollsRemaining}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stop Process Confirmation Dialog ─────────────────────────────────────────
|
||||
|
||||
interface StopConfirmationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companionName: string;
|
||||
processType: 'incubation' | 'evolution';
|
||||
onConfirm: () => Promise<void>;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
function StopConfirmationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companionName,
|
||||
processType,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: StopConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const label = processType === 'incubation' ? 'Incubation' : 'Evolution';
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-amber-500" />
|
||||
Stop {label}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<p>
|
||||
Are you sure you want to stop {processType === 'incubation' ? 'incubating' : 'evolving'}{' '}
|
||||
<strong>{companionName}</strong>?
|
||||
</p>
|
||||
<p>
|
||||
This will interrupt the {processType} process and clear all task progress.
|
||||
You can restart {processType} later, but you'll need to complete the tasks again.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
`Stop ${label}`
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Process Content (Incubation or Evolution) ────────────────────────────────
|
||||
|
||||
interface ProcessContentProps {
|
||||
companion: BlobbiCompanion;
|
||||
tasks: HatchTasksResult | EvolveTasksResult;
|
||||
processType: 'incubation' | 'evolution';
|
||||
onOpenPostModal: () => void;
|
||||
onComplete: () => void;
|
||||
isCompleting: boolean;
|
||||
onStop: () => Promise<void>;
|
||||
isStopping: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function ProcessContent({
|
||||
companion,
|
||||
tasks,
|
||||
processType,
|
||||
onOpenPostModal,
|
||||
onComplete,
|
||||
isCompleting,
|
||||
onStop,
|
||||
isStopping,
|
||||
defaultOpen = true,
|
||||
}: ProcessContentProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
|
||||
|
||||
const isIncubation = processType === 'incubation';
|
||||
const emoji = isIncubation ? '🥚' : '🐣';
|
||||
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
|
||||
const description = isIncubation
|
||||
? 'Complete these tasks to hatch your Blobbi'
|
||||
: 'Complete these tasks to evolve your Blobbi';
|
||||
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
|
||||
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
|
||||
const completeEmoji = isIncubation ? '🐣' : '✨';
|
||||
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
|
||||
|
||||
const completedCount = tasks.tasks.filter(t => t.completed).length;
|
||||
const totalTasks = tasks.tasks.length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||
{/* Section header - Clickable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{emoji}</span>
|
||||
<h3 className="font-semibold text-sm">{title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded-full",
|
||||
tasks.allCompleted
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{completedCount}/{totalTasks}
|
||||
</span>
|
||||
<ChevronDown className={cn(
|
||||
"size-4 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Tasks content */}
|
||||
<CollapsibleContent className="pt-3">
|
||||
{/* Tasks Panel */}
|
||||
<TasksPanel
|
||||
tasks={tasks.tasks}
|
||||
allCompleted={tasks.allCompleted}
|
||||
isLoading={tasks.isLoading}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onComplete}
|
||||
isCompleting={isCompleting}
|
||||
emoji={emoji}
|
||||
title={title}
|
||||
description={description}
|
||||
completeLabel={completeLabel}
|
||||
completingLabel={completingLabel}
|
||||
completeEmoji={completeEmoji}
|
||||
/>
|
||||
|
||||
{/* Stop Process Button */}
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowStopConfirmation(true)}
|
||||
disabled={isStopping || isCompleting}
|
||||
className="w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-4 mr-2" />
|
||||
{stopLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
{/* Stop Confirmation Dialog */}
|
||||
<StopConfirmationDialog
|
||||
open={showStopConfirmation}
|
||||
onOpenChange={setShowStopConfirmation}
|
||||
companionName={companion.name}
|
||||
processType={processType}
|
||||
onConfirm={onStop}
|
||||
isPending={isStopping}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Modal ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiMissionsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
hatchTasks,
|
||||
evolveTasks,
|
||||
onOpenPostModal,
|
||||
onHatch,
|
||||
isHatching,
|
||||
onEvolve,
|
||||
isEvolving,
|
||||
onStopIncubation,
|
||||
isStoppingIncubation,
|
||||
onStopEvolution,
|
||||
isStoppingEvolution,
|
||||
availableStages,
|
||||
}: BlobbiMissionsModalProps) {
|
||||
const isIncubating = companion.state === 'incubating';
|
||||
const isEvolvingState = companion.state === 'evolving';
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isBaby = companion.stage === 'baby';
|
||||
|
||||
// Check if there's an active hatch/evolve process
|
||||
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
|
||||
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Target className="size-5 shrink-0" />
|
||||
Missions
|
||||
</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
Complete missions to earn rewards for {companion.name}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-6 py-3 sm:py-4 space-y-4">
|
||||
{/* Daily Missions Section - Always visible, expanded by default */}
|
||||
<DailyMissionsSection
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
availableStages={availableStages}
|
||||
disabled={isProcessBusy}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
|
||||
{/* Hatch/Evolve Process Section - Only when active, expanded by default */}
|
||||
{hasActiveProcess && (
|
||||
<>
|
||||
{isIncubating && isEgg ? (
|
||||
<ProcessContent
|
||||
companion={companion}
|
||||
tasks={hatchTasks}
|
||||
processType="incubation"
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onHatch}
|
||||
isCompleting={isHatching}
|
||||
onStop={onStopIncubation}
|
||||
isStopping={isStoppingIncubation}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
) : isEvolvingState && isBaby ? (
|
||||
<ProcessContent
|
||||
companion={companion}
|
||||
tasks={evolveTasks}
|
||||
processType="evolution"
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onEvolve}
|
||||
isCompleting={isEvolving}
|
||||
onStop={onStopEvolution}
|
||||
isStopping={isStoppingEvolution}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// src/blobbi/actions/components/BlobbiPostModal.tsx
|
||||
|
||||
/**
|
||||
* Modal for creating a Blobbi post (hatch or evolve).
|
||||
*
|
||||
* Requirements:
|
||||
* - Prefilled with stage-aware text:
|
||||
* - Hatch: "Hello Nostr! Posting to hatch #<blobbiName> #blobbi #ditto #nostr"
|
||||
* - Evolve: "Hello Nostr! Posting to evolve #<blobbiName> #blobbi #ditto #nostr"
|
||||
* - User can ADD text but CANNOT delete the prefix or required hashtags
|
||||
* - Blobbi name is sanitized into a valid hashtag format
|
||||
* - Enforced programmatically
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { X, Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
} from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The process type for the post */
|
||||
export type BlobbiPostProcess = 'hatch' | 'evolve';
|
||||
|
||||
interface BlobbiPostModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi's name (will be converted to hashtag) */
|
||||
blobbiName: string;
|
||||
/** The process type - 'hatch' for incubation, 'evolve' for evolution */
|
||||
process?: BlobbiPostProcess;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitize a name into a valid hashtag format.
|
||||
* - Removes special characters
|
||||
* - Replaces spaces with nothing (camelCase-like)
|
||||
* - Ensures lowercase
|
||||
* - Handles edge cases
|
||||
*/
|
||||
function sanitizeToHashtag(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
// Remove emojis and special characters, keep letters, numbers, underscores
|
||||
.replace(/[^\p{L}\p{N}_]/gu, '')
|
||||
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
|
||||
.replace(/^(\d)/, 'blobbi$1')
|
||||
// Limit length
|
||||
.slice(0, 30)
|
||||
// Fallback if empty
|
||||
|| 'myblobbi';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the required prefix text based on process type.
|
||||
*/
|
||||
function buildPrefix(process: BlobbiPostProcess): string {
|
||||
return process === 'evolve'
|
||||
? 'Hello Nostr! Posting to evolve'
|
||||
: 'Hello Nostr! Posting to hatch';
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiPostModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
blobbiName,
|
||||
process = 'hatch',
|
||||
onSuccess,
|
||||
}: BlobbiPostModalProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||||
|
||||
// Compute the required elements based on props
|
||||
const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]);
|
||||
const prefix = useMemo(() => buildPrefix(process), [process]);
|
||||
|
||||
// All required hashtags including the Blobbi name (first)
|
||||
const allRequiredHashtags = useMemo(() =>
|
||||
[blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS],
|
||||
[blobbiHashtag]
|
||||
);
|
||||
|
||||
// Build default content
|
||||
const defaultContent = useMemo(() =>
|
||||
`${prefix} #${allRequiredHashtags.join(' #')}`,
|
||||
[prefix, allRequiredHashtags]
|
||||
);
|
||||
|
||||
const [content, setContent] = useState(defaultContent);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
// Reset content when modal opens or props change
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setContent(defaultContent);
|
||||
setValidationError(null);
|
||||
}
|
||||
}, [open, defaultContent]);
|
||||
|
||||
/**
|
||||
* Validate that the content still contains the required prefix and hashtags.
|
||||
*/
|
||||
const validateContent = useCallback((text: string): string | null => {
|
||||
// Check prefix
|
||||
if (!text.startsWith(prefix)) {
|
||||
return 'The post must start with the required text';
|
||||
}
|
||||
|
||||
// Check all required hashtags are present (including Blobbi name)
|
||||
const lowerText = text.toLowerCase();
|
||||
for (const tag of allRequiredHashtags) {
|
||||
if (!lowerText.includes(`#${tag.toLowerCase()}`)) {
|
||||
return `Missing required hashtag: #${tag}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [prefix, allRequiredHashtags]);
|
||||
|
||||
/**
|
||||
* Handle content change with validation.
|
||||
* Prevents deletion of required content.
|
||||
*/
|
||||
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newContent = e.target.value;
|
||||
|
||||
// Allow content changes only if it preserves the required elements
|
||||
const error = validateContent(newContent);
|
||||
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
// Still update content but show error
|
||||
// This allows the user to see what they're trying to do
|
||||
// but the post button will be disabled
|
||||
} else {
|
||||
setValidationError(null);
|
||||
}
|
||||
|
||||
setContent(newContent);
|
||||
}, [validateContent]);
|
||||
|
||||
/**
|
||||
* Handle post creation.
|
||||
*/
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!user?.pubkey) {
|
||||
toast({
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to create a post',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Final validation
|
||||
const error = validateContent(content);
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tags for the post
|
||||
const tags: string[][] = [];
|
||||
|
||||
// Add all required hashtags as 't' tags
|
||||
for (const hashtag of allRequiredHashtags) {
|
||||
tags.push(['t', hashtag.toLowerCase()]);
|
||||
}
|
||||
|
||||
// Extract any additional hashtags the user added
|
||||
const additionalHashtags = content.match(/#(\w+)/g) || [];
|
||||
const requiredLower = allRequiredHashtags.map(t => t.toLowerCase());
|
||||
for (const tag of additionalHashtags) {
|
||||
const tagValue = tag.slice(1).toLowerCase();
|
||||
if (!requiredLower.includes(tagValue)) {
|
||||
tags.push(['t', tagValue]);
|
||||
}
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content,
|
||||
tags,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Post created!',
|
||||
description: process === 'evolve'
|
||||
? 'Your Blobbi evolution post has been published.'
|
||||
: 'Your Blobbi hatch post has been published.',
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to create post',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]);
|
||||
|
||||
const canPost = !validationError && content.trim().length > 0;
|
||||
|
||||
const dialogTitle = process === 'evolve' ? 'Blobbi Evolution Post' : 'Blobbi Hatch Post';
|
||||
const alertText = process === 'evolve'
|
||||
? "This special post announces your Blobbi's evolution! The highlighted text must remain in your post."
|
||||
: "This special post announces your Blobbi's hatching journey! The highlighted text must remain in your post.";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg p-0 gap-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-14 border-b">
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{dialogTitle}
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Info alert */}
|
||||
<Alert className="border-primary/20 bg-primary/5">
|
||||
<AlertDescription className="text-sm">
|
||||
{alertText}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="Write your post..."
|
||||
className="min-h-[150px] resize-none"
|
||||
disabled={isPending}
|
||||
/>
|
||||
|
||||
{/* Character count and validation */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
{validationError && (
|
||||
<span className="text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{validationError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview of required content */}
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
|
||||
<p className="text-xs text-muted-foreground mb-1">Required content:</p>
|
||||
<p className="text-sm font-medium">
|
||||
<span className="text-primary">{prefix}</span>
|
||||
{' '}
|
||||
{allRequiredHashtags.map(tag => (
|
||||
<span key={tag} className="text-blue-500">#{tag} </span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/30">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePost}
|
||||
disabled={!canPost || isPending}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
'Post'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* DailyMissionsPanel - UI component for displaying daily missions
|
||||
*
|
||||
* Shows:
|
||||
* - Daily mission list with progress bars
|
||||
* - Completion state
|
||||
* - Claim buttons for completed missions
|
||||
* - Coin rewards
|
||||
* - Bonus mission after completing all regular missions
|
||||
* - Empty state when no missions available (egg-only users)
|
||||
* - Reroll button to replace missions (max 3/day)
|
||||
*/
|
||||
|
||||
import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
import type { DailyMission } from '../lib/daily-missions';
|
||||
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsPanelProps {
|
||||
/** The daily missions to display */
|
||||
missions: DailyMission[];
|
||||
/** Callback when claiming a mission reward */
|
||||
onClaimReward: (missionId: string) => void;
|
||||
/** Callback when rerolling a mission */
|
||||
onRerollMission?: (missionId: string) => void;
|
||||
/** Total coins earned today */
|
||||
todayCoins: number;
|
||||
/** Whether claiming is disabled (e.g., during another operation) */
|
||||
disabled?: boolean;
|
||||
/** Whether the bonus mission is available */
|
||||
bonusAvailable?: boolean;
|
||||
/** Whether the bonus mission has been claimed */
|
||||
bonusClaimed?: boolean;
|
||||
/** Bonus mission reward amount */
|
||||
bonusReward?: number;
|
||||
/** Whether user has no eligible missions (e.g., only eggs) */
|
||||
noMissionsAvailable?: boolean;
|
||||
/** Number of rerolls remaining today */
|
||||
rerollsRemaining?: number;
|
||||
/** Whether a reroll is currently in progress */
|
||||
isRerolling?: boolean;
|
||||
}
|
||||
|
||||
// ─── Mission Item ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface MissionItemProps {
|
||||
mission: DailyMission;
|
||||
onClaim: () => void;
|
||||
onReroll?: () => void;
|
||||
disabled?: boolean;
|
||||
canReroll?: boolean;
|
||||
isRerolling?: boolean;
|
||||
}
|
||||
|
||||
function MissionItem({ mission, onClaim, onReroll, disabled, canReroll = false, isRerolling = false }: MissionItemProps) {
|
||||
const progressPercent = (mission.currentCount / mission.requiredCount) * 100;
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
|
||||
// Can only reroll if: not completed, not claimed, has reroll callback, and has rerolls remaining
|
||||
const showRerollButton = onReroll && !mission.completed && !mission.claimed && canReroll;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-3 sm:p-4 rounded-lg border transition-colors overflow-hidden',
|
||||
mission.claimed
|
||||
? 'bg-primary/5 border-primary/20'
|
||||
: mission.completed
|
||||
? 'bg-green-500/5 border-green-500/30'
|
||||
: 'bg-card border-border'
|
||||
)}
|
||||
>
|
||||
{/* Top right area: Claimed badge OR Reroll button */}
|
||||
<div className="absolute top-2 right-2">
|
||||
{mission.claimed ? (
|
||||
<div className="flex items-center gap-1 text-xs text-primary font-medium">
|
||||
<Check className="size-3" />
|
||||
Claimed
|
||||
</div>
|
||||
) : showRerollButton ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={onReroll}
|
||||
disabled={disabled || isRerolling}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", isRerolling && "animate-spin")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Replace this mission</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Mission content */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Title and description */}
|
||||
<div className="pr-14 sm:pr-16">
|
||||
<h4 className="font-medium text-sm break-words">{mission.title}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 break-words">
|
||||
{mission.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
{mission.currentCount} / {mission.requiredCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||
<Coins className="size-3 shrink-0" />
|
||||
{formatCompactNumber(mission.reward)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progressPercent}
|
||||
className={cn(
|
||||
'h-2',
|
||||
mission.completed && '[&>div]:bg-green-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{canClaim && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Gift className="size-4 mr-2 shrink-0" />
|
||||
<span className="truncate">Claim {formatCompactNumber(mission.reward)} Coins</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bonus Mission Item ───────────────────────────────────────────────────────
|
||||
|
||||
interface BonusMissionItemProps {
|
||||
isAvailable: boolean;
|
||||
isClaimed: boolean;
|
||||
reward: number;
|
||||
onClaim: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-3 sm:p-4 rounded-lg border-2 transition-colors overflow-hidden',
|
||||
isClaimed
|
||||
? 'bg-amber-500/10 border-amber-500/30'
|
||||
: isAvailable
|
||||
? 'bg-gradient-to-br from-amber-500/10 to-orange-500/10 border-amber-500/40 animate-pulse'
|
||||
: 'bg-muted/30 border-dashed border-muted-foreground/20'
|
||||
)}
|
||||
>
|
||||
{/* Claimed badge */}
|
||||
{isClaimed && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
|
||||
<Check className="size-3" />
|
||||
Claimed
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mission content */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Title and description */}
|
||||
<div className={cn("pr-14 sm:pr-16", !isAvailable && !isClaimed && "opacity-50")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className={cn(
|
||||
"size-4 shrink-0",
|
||||
isClaimed
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: isAvailable
|
||||
? "text-amber-500"
|
||||
: "text-muted-foreground"
|
||||
)} />
|
||||
<h4 className="font-medium text-sm">Daily Champion</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{isAvailable || isClaimed
|
||||
? 'Bonus reward for completing all daily missions!'
|
||||
: 'Complete all missions above to unlock this bonus'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reward display */}
|
||||
<div className="flex items-center justify-between text-xs gap-2">
|
||||
<span className={cn(
|
||||
"text-muted-foreground",
|
||||
!isAvailable && !isClaimed && "opacity-50"
|
||||
)}>
|
||||
Bonus Reward
|
||||
</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 font-medium",
|
||||
isClaimed || isAvailable
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
<Coins className="size-3 shrink-0" />
|
||||
+{formatCompactNumber(reward)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{isAvailable && !isClaimed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
<Trophy className="size-4 mr-2 shrink-0" />
|
||||
<span className="truncate">Claim Bonus {formatCompactNumber(reward)} Coins!</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── No Missions Available State ──────────────────────────────────────────────
|
||||
|
||||
function NoMissionsState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<Egg className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">Hatch Your Blobbi First</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Daily missions will be available once you have
|
||||
<br />
|
||||
a hatched Blobbi to interact with!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── All Claimed State ────────────────────────────────────────────────────────
|
||||
|
||||
interface AllClaimedStateProps {
|
||||
todayCoins: number;
|
||||
}
|
||||
|
||||
function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="size-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="size-6 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">All Done for Today!</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You earned <span className="font-medium text-amber-600 dark:text-amber-400">{formatCompactNumber(todayCoins)} coins</span> today.
|
||||
<br />
|
||||
Come back tomorrow for new missions!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reroll Counter ───────────────────────────────────────────────────────────
|
||||
|
||||
interface RerollCounterProps {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
function RerollCounter({ remaining }: RerollCounterProps) {
|
||||
const text = remaining === 0
|
||||
? 'No rerolls left'
|
||||
: remaining === 1
|
||||
? '1 reroll left'
|
||||
: `${remaining} rerolls left`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<RefreshCw className="size-3" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function DailyMissionsPanel({
|
||||
missions,
|
||||
onClaimReward,
|
||||
onRerollMission,
|
||||
todayCoins,
|
||||
disabled,
|
||||
bonusAvailable = false,
|
||||
bonusClaimed = false,
|
||||
bonusReward = 50,
|
||||
noMissionsAvailable = false,
|
||||
rerollsRemaining = 0,
|
||||
isRerolling = false,
|
||||
}: DailyMissionsPanelProps) {
|
||||
// Show empty state if user has no eligible missions (e.g., only eggs)
|
||||
if (noMissionsAvailable) {
|
||||
return <NoMissionsState />;
|
||||
}
|
||||
|
||||
const allRegularClaimed = missions.every((m) => m.claimed);
|
||||
const allDone = allRegularClaimed && bonusClaimed;
|
||||
|
||||
// Show "all done" state only when everything including bonus is claimed
|
||||
if (allDone) {
|
||||
return <AllClaimedState todayCoins={todayCoins} />;
|
||||
}
|
||||
|
||||
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Reroll counter - only show if reroll functionality is available */}
|
||||
{onRerollMission && (
|
||||
<RerollCounter remaining={rerollsRemaining} />
|
||||
)}
|
||||
|
||||
{/* Regular missions */}
|
||||
{missions.map((mission) => (
|
||||
<MissionItem
|
||||
key={mission.id}
|
||||
mission={mission}
|
||||
onClaim={() => onClaimReward(mission.id)}
|
||||
onReroll={onRerollMission ? () => onRerollMission(mission.id) : undefined}
|
||||
disabled={disabled}
|
||||
canReroll={canReroll}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bonus mission - always visible */}
|
||||
<BonusMissionItem
|
||||
isAvailable={bonusAvailable}
|
||||
isClaimed={bonusClaimed}
|
||||
reward={bonusReward}
|
||||
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// src/blobbi/actions/components/HatchTasksPanel.tsx
|
||||
|
||||
/**
|
||||
* UI component for displaying hatch task progress.
|
||||
* Shows a list of tasks with progress indicators and action buttons.
|
||||
*/
|
||||
|
||||
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HatchTasksPanelProps {
|
||||
tasks: HatchTask[];
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
/** Called when user clicks "Create Post" action */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all tasks are complete and user clicks "Hatch" */
|
||||
onHatch: () => void;
|
||||
/** Whether hatching is in progress */
|
||||
isHatching?: boolean;
|
||||
}
|
||||
|
||||
// ─── Task Row Component ───────────────────────────────────────────────────────
|
||||
|
||||
interface TaskRowProps {
|
||||
task: HatchTask;
|
||||
onOpenPostModal: () => void;
|
||||
}
|
||||
|
||||
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') {
|
||||
onOpenPostModal();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const progress = task.required > 1
|
||||
? Math.round((task.current / task.required) * 100)
|
||||
: task.completed ? 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 p-4 rounded-xl border transition-all",
|
||||
task.completed
|
||||
? "bg-emerald-500/5 border-emerald-500/20"
|
||||
: "bg-card/60 border-border hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className={cn(
|
||||
"size-10 rounded-full flex items-center justify-center shrink-0",
|
||||
task.completed
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{task.completed ? (
|
||||
<Check className="size-5" />
|
||||
) : task.required > 1 ? (
|
||||
<span className="text-sm font-medium">{task.current}/{task.required}</span>
|
||||
) : (
|
||||
<span className="text-lg">○</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className={cn(
|
||||
"font-medium",
|
||||
task.completed && "text-emerald-600 dark:text-emerald-400"
|
||||
)}>
|
||||
{task.name}
|
||||
</h4>
|
||||
{task.completed && (
|
||||
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs">
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
|
||||
{/* Progress bar for multi-step tasks */}
|
||||
{task.required > 1 && !task.completed && (
|
||||
<Progress value={progress} className="h-1.5 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAction}
|
||||
className="shrink-0 gap-2"
|
||||
>
|
||||
{task.actionLabel}
|
||||
{task.action === 'external_link' ? (
|
||||
<ExternalLink className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function HatchTasksPanel({
|
||||
tasks,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
onOpenPostModal,
|
||||
onHatch,
|
||||
isHatching = false,
|
||||
}: HatchTasksPanelProps) {
|
||||
const completedCount = tasks.filter(t => t.completed).length;
|
||||
const totalTasks = tasks.length;
|
||||
const overallProgress = Math.round((completedCount / totalTasks) * 100);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🥚</span>
|
||||
Hatch Tasks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Complete these tasks to hatch your Blobbi
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-base px-3 py-1">
|
||||
{completedCount}/{totalTasks}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Overall progress */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted-foreground">Overall progress</span>
|
||||
<span className="font-medium">{overallProgress}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} className="h-2" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tasks.map(task => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hatch button - only visible when all tasks complete */}
|
||||
{allCompleted && (
|
||||
<div className="pt-4 border-t border-border mt-4">
|
||||
<Button
|
||||
onClick={onHatch}
|
||||
disabled={isHatching}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
{isHatching ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
Hatching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl">🐣</span>
|
||||
Hatch Your Blobbi!
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/blobbi/actions/components/InlineMusicPlayer.tsx
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Music, Play, Pause, RotateCcw, MoreHorizontal, Loader2, AlertCircle, X, Volume2, VolumeX } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useAudioPlayback } from '../hooks/useAudioPlayback';
|
||||
import type { SelectedTrack } from './PlayMusicModal';
|
||||
|
||||
// Re-export for external use
|
||||
export type { SelectedTrack } from './PlayMusicModal';
|
||||
|
||||
interface InlineMusicPlayerProps {
|
||||
/** The selected track */
|
||||
selection: SelectedTrack;
|
||||
/** Called when user wants to change the track */
|
||||
onChangeTrack: () => void;
|
||||
/** Called when user closes the player */
|
||||
onClose: () => void;
|
||||
/** Called when playback starts (for Blobbi reaction state) */
|
||||
onPlaybackStart?: () => void;
|
||||
/** Called when playback stops/pauses (for Blobbi reaction state) */
|
||||
onPlaybackStop?: () => void;
|
||||
/** Whether the action has been published (playback only starts after publish) */
|
||||
isPublished: boolean;
|
||||
/** Whether publishing is in progress */
|
||||
isPublishing: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function InlineMusicPlayer({
|
||||
selection,
|
||||
onChangeTrack,
|
||||
onClose,
|
||||
onPlaybackStart,
|
||||
onPlaybackStop,
|
||||
isPublished,
|
||||
isPublishing,
|
||||
}: InlineMusicPlayerProps) {
|
||||
const {
|
||||
state: playbackState,
|
||||
error: playbackError,
|
||||
load,
|
||||
toggle,
|
||||
restart,
|
||||
stop,
|
||||
isPlaying,
|
||||
volume,
|
||||
setVolume,
|
||||
cleanup,
|
||||
} = useAudioPlayback({
|
||||
onEnded: () => {
|
||||
onPlaybackStop?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-start playback when first published (idle -> playing)
|
||||
// Note: 'stopped' state is NOT included here - stop is a terminal state
|
||||
// that requires explicit user action (play button) to restart
|
||||
useEffect(() => {
|
||||
if (isPublished && playbackState === 'idle') {
|
||||
load(selection.url, true);
|
||||
onPlaybackStart?.();
|
||||
}
|
||||
}, [isPublished, playbackState, selection.url, load, onPlaybackStart]);
|
||||
|
||||
// Force reload when source URL changes while already playing/paused
|
||||
useEffect(() => {
|
||||
// Only trigger reload if we're in an active playback state with a different URL
|
||||
if (isPublished && (playbackState === 'playing' || playbackState === 'paused')) {
|
||||
// The load function will check if URL changed and reload if needed
|
||||
load(selection.url, true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to selection.url changes
|
||||
}, [selection.url]);
|
||||
|
||||
// Notify on playback state changes
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
onPlaybackStart?.();
|
||||
} else if (playbackState === 'paused' || playbackState === 'stopped') {
|
||||
onPlaybackStop?.();
|
||||
}
|
||||
}, [isPlaying, playbackState, onPlaybackStart, onPlaybackStop]);
|
||||
|
||||
// Cleanup on close
|
||||
const handleClose = useCallback(() => {
|
||||
stop();
|
||||
cleanup();
|
||||
onPlaybackStop?.();
|
||||
onClose();
|
||||
}, [stop, cleanup, onPlaybackStop, onClose]);
|
||||
|
||||
// Handle play/pause toggle
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (playbackState === 'idle' || playbackState === 'stopped') {
|
||||
load(selection.url, true);
|
||||
} else {
|
||||
await toggle();
|
||||
}
|
||||
}, [playbackState, selection.url, load, toggle]);
|
||||
|
||||
// Track info
|
||||
const trackTitle = selection.track.title;
|
||||
const trackArtist = selection.track.artist;
|
||||
|
||||
const isLoading = playbackState === 'loading' || isPublishing;
|
||||
const hasError = playbackState === 'error';
|
||||
|
||||
return (
|
||||
<div className="mx-4 sm:mx-6 mb-4">
|
||||
<div className={cn(
|
||||
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
|
||||
"shadow-sm transition-all",
|
||||
isPlaying && "ring-2 ring-pink-500/30"
|
||||
)}>
|
||||
{/* Main content row */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
{/* Music icon / Now Playing indicator */}
|
||||
<div className={cn(
|
||||
"size-10 rounded-lg flex items-center justify-center shrink-0",
|
||||
isPlaying
|
||||
? "bg-pink-500/20"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
<Music className={cn(
|
||||
"size-5",
|
||||
isPlaying ? "text-pink-500 animate-pulse" : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{trackTitle}</p>
|
||||
{trackArtist && (
|
||||
<p className="text-xs text-muted-foreground truncate">{trackArtist}</p>
|
||||
)}
|
||||
{!trackArtist && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isPlaying ? 'Now playing...' : isPublishing ? 'Starting...' : 'Ready to play'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Play/Pause button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || !isPublished}
|
||||
className="size-9 rounded-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Restart button - only show when actively playing or paused */}
|
||||
{isPublished && (playbackState === 'playing' || playbackState === 'paused') && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
restart();
|
||||
}}
|
||||
className="size-9 rounded-full"
|
||||
title="Restart from beginning"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Volume control */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-9 rounded-full"
|
||||
title={volume === 0 ? 'Unmute' : 'Volume'}
|
||||
>
|
||||
{volume === 0 ? (
|
||||
<VolumeX className="size-4" />
|
||||
) : (
|
||||
<Volume2 className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="w-32 p-3"
|
||||
>
|
||||
<Slider
|
||||
value={[volume * 100]}
|
||||
onValueChange={([val]) => setVolume(val / 100)}
|
||||
max={100}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Change track button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onChangeTrack}
|
||||
disabled={isPublishing}
|
||||
className="size-9 rounded-full"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isPublishing}
|
||||
className="size-9 rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{hasError && playbackError && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||
<p className="text-xs">{playbackError.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// src/blobbi/actions/components/InlineSingCard.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Mic,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
FileText,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useAudioPlayback } from '../hooks/useAudioPlayback';
|
||||
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'error';
|
||||
|
||||
interface InlineSingCardProps {
|
||||
/** Called when user confirms the singing action (publish the action) */
|
||||
onConfirm: () => Promise<void>;
|
||||
/** Called when user closes the sing card */
|
||||
onClose: () => void;
|
||||
/** Called when recording starts (for Blobbi reaction) */
|
||||
onRecordingStart?: () => void;
|
||||
/** Called when recording stops (for Blobbi reaction) */
|
||||
onRecordingStop?: () => void;
|
||||
/** Whether publishing is in progress */
|
||||
isPublishing: boolean;
|
||||
}
|
||||
|
||||
// ─── MIME Type Selection ──────────────────────────────────────────────────────
|
||||
|
||||
const AUDIO_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/ogg',
|
||||
] as const;
|
||||
|
||||
function getSupportedAudioMimeType(): string | undefined {
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const mimeType of AUDIO_MIME_CANDIDATES) {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function InlineSingCard({
|
||||
onConfirm,
|
||||
onClose,
|
||||
onRecordingStart,
|
||||
onRecordingStop,
|
||||
isPublishing,
|
||||
}: InlineSingCardProps) {
|
||||
// Recording state
|
||||
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
|
||||
const [recordingError, setRecordingError] = useState<string | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
|
||||
// Lyrics state
|
||||
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
// Refs
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const actualMimeTypeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Audio playback for preview
|
||||
const {
|
||||
state: playbackState,
|
||||
error: playbackError,
|
||||
load: loadAudio,
|
||||
toggle: togglePlayback,
|
||||
stop: stopPlayback,
|
||||
isPlaying,
|
||||
cleanup: cleanupPlayback,
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAll();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup all resources
|
||||
const cleanupAll = useCallback(() => {
|
||||
// Stop timer
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop media recorder
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorderRef.current.stop();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
|
||||
// Stop stream tracks
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Cleanup playback
|
||||
cleanupPlayback();
|
||||
|
||||
// Revoke URL
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
}, [audioUrl, cleanupPlayback]);
|
||||
|
||||
// Reset recording
|
||||
const resetRecording = useCallback(() => {
|
||||
cleanupAll();
|
||||
setRecordingState('idle');
|
||||
setRecordingError(null);
|
||||
setRecordingDuration(0);
|
||||
setAudioUrl(null);
|
||||
chunksRef.current = [];
|
||||
actualMimeTypeRef.current = undefined;
|
||||
// Keep lyrics
|
||||
}, [cleanupAll]);
|
||||
|
||||
// Check browser support
|
||||
const checkRecordingSupport = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
if (!navigator.mediaDevices) return false;
|
||||
if (!navigator.mediaDevices.getUserMedia) return false;
|
||||
if (typeof MediaRecorder === 'undefined') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!checkRecordingSupport()) {
|
||||
setRecordingError('Audio recording is not supported in this browser.');
|
||||
setRecordingState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingState('requesting');
|
||||
setRecordingError(null);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
}
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
|
||||
// Get supported MIME type
|
||||
const supportedMimeType = getSupportedAudioMimeType();
|
||||
|
||||
// Create MediaRecorder
|
||||
let mediaRecorder: MediaRecorder;
|
||||
if (supportedMimeType) {
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
|
||||
} else {
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
}
|
||||
|
||||
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
|
||||
const blob = new Blob(chunksRef.current, { type: blobMimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
setRecordingState('recorded');
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
setRecordingError('Recording failed. Please try again.');
|
||||
setRecordingState('error');
|
||||
};
|
||||
|
||||
mediaRecorder.start(100);
|
||||
setRecordingState('recording');
|
||||
setRecordingDuration(0);
|
||||
|
||||
// Notify parent that recording started (for Blobbi reaction)
|
||||
onRecordingStart?.();
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||
setRecordingError('Microphone access was denied.');
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
setRecordingError('No microphone found.');
|
||||
} else {
|
||||
setRecordingError(err.message);
|
||||
}
|
||||
} else {
|
||||
setRecordingError('Failed to access microphone.');
|
||||
}
|
||||
setRecordingState('error');
|
||||
}
|
||||
}, [onRecordingStart]);
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
// Notify parent that recording stopped (for Blobbi reaction)
|
||||
onRecordingStop?.();
|
||||
}, [onRecordingStop]);
|
||||
|
||||
// Handle preview playback
|
||||
const handlePreview = useCallback(() => {
|
||||
if (!audioUrl) return;
|
||||
|
||||
if (playbackState === 'idle') {
|
||||
loadAudio(audioUrl, true);
|
||||
} else {
|
||||
togglePlayback();
|
||||
}
|
||||
}, [audioUrl, playbackState, loadAudio, togglePlayback]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(async () => {
|
||||
stopPlayback();
|
||||
await onConfirm();
|
||||
// After successful publish, close the card
|
||||
onClose();
|
||||
}, [stopPlayback, onConfirm, onClose]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback(() => {
|
||||
cleanupAll();
|
||||
onClose();
|
||||
}, [cleanupAll, onClose]);
|
||||
|
||||
// Handle lyrics toggle
|
||||
const handleLyricsToggle = useCallback(() => {
|
||||
if (!currentLyrics && !showLyrics) {
|
||||
// Generate lyrics on first open
|
||||
setCurrentLyrics(getRandomLyrics());
|
||||
}
|
||||
setShowLyrics(!showLyrics);
|
||||
}, [currentLyrics, showLyrics]);
|
||||
|
||||
// Get new lyrics
|
||||
const handleNewLyrics = useCallback(() => {
|
||||
setCurrentLyrics(getRandomLyrics());
|
||||
}, []);
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const hasRecording = recordingState === 'recorded';
|
||||
const isRecording = recordingState === 'recording';
|
||||
const canConfirm = hasRecording && !isPublishing;
|
||||
|
||||
return (
|
||||
<div className="mx-4 sm:mx-6 mb-4">
|
||||
<div className={cn(
|
||||
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
|
||||
"shadow-sm transition-all",
|
||||
isRecording && "ring-2 ring-red-500/30"
|
||||
)}>
|
||||
{/* Lyrics panel (expands upward visually by being above controls) */}
|
||||
{showLyrics && currentLyrics && (
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{currentLyrics.title}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleNewLyrics}
|
||||
className="size-7 rounded-full"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50 text-sm leading-relaxed whitespace-pre-line max-h-32 overflow-y-auto">
|
||||
{currentLyrics.lines.join('\n')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status row (recording/recorded info) */}
|
||||
{(isRecording || hasRecording) && (
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isRecording && (
|
||||
<>
|
||||
<div className="size-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-sm font-mono font-medium text-red-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Recording...</span>
|
||||
</>
|
||||
)}
|
||||
{hasRecording && !isRecording && (
|
||||
<>
|
||||
<Check className="size-4 text-purple-500" />
|
||||
<span className="text-sm font-mono font-medium text-purple-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Recorded</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{(recordingError || playbackError) && (
|
||||
<div className="px-3 pt-2">
|
||||
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||
<p className="text-xs">{recordingError || playbackError?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main controls row */}
|
||||
<div className="flex items-center justify-between gap-2 p-3">
|
||||
{/* Left: Lyrics button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant={showLyrics ? "secondary" : "ghost"}
|
||||
onClick={handleLyricsToggle}
|
||||
className="size-10 rounded-full shrink-0"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Center: Record/Stop button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isRecording && !hasRecording && (
|
||||
<Button
|
||||
onClick={startRecording}
|
||||
disabled={isPublishing}
|
||||
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
<Mic className="size-4 mr-2" />
|
||||
Sing
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<Button
|
||||
onClick={stopRecording}
|
||||
variant="destructive"
|
||||
className="rounded-full px-6"
|
||||
>
|
||||
<Square className="size-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasRecording && !isRecording && (
|
||||
<>
|
||||
<Button
|
||||
onClick={resetRecording}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-10 rounded-full"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4 mr-2" />
|
||||
)}
|
||||
{isPublishing ? 'Singing...' : 'Sing for Blobbi'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Preview button (when recording exists) */}
|
||||
{hasRecording ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handlePreview}
|
||||
disabled={isPublishing}
|
||||
className="size-10 rounded-full shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
/* Close button when no recording */
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
className="size-10 rounded-full shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close button row when recording exists */}
|
||||
{hasRecording && (
|
||||
<div className="px-3 pb-3 pt-0 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isPublishing}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// src/blobbi/actions/components/PlayMusicModal.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Music, Play, Pause, Check, Loader2, Volume2, AlertCircle } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
getAllTracks,
|
||||
formatTrackDuration,
|
||||
type BlobbiTrack,
|
||||
} from '../lib/blobbi-track-catalog';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Selected track for the music player
|
||||
*/
|
||||
export interface SelectedTrack {
|
||||
track: BlobbiTrack;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlayMusicModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called with the selected track when user confirms */
|
||||
onConfirm: (selection: SelectedTrack) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PlayMusicModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: PlayMusicModalProps) {
|
||||
const [selectedTrack, setSelectedTrack] = useState<SelectedTrack | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// Track the current audio source URL to detect changes
|
||||
const currentAudioUrlRef = useRef<string | null>(null);
|
||||
|
||||
const tracks = getAllTracks();
|
||||
|
||||
// Cleanup audio on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedTrack(null);
|
||||
setIsPlaying(false);
|
||||
setError(null);
|
||||
currentAudioUrlRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Handle selecting a track
|
||||
const handleSelectTrack = useCallback((track: BlobbiTrack) => {
|
||||
// Stop current playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
setSelectedTrack({ track, url: track.url });
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Handle play/pause preview
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (!selectedTrack) return;
|
||||
|
||||
const audioUrl = selectedTrack.url;
|
||||
|
||||
// Check if we need to create a new Audio instance (source changed or first time)
|
||||
const needsNewAudio = !audioRef.current || currentAudioUrlRef.current !== audioUrl;
|
||||
|
||||
if (needsNewAudio) {
|
||||
// Stop and cleanup old audio if exists
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
}
|
||||
|
||||
// Create new Audio instance with the correct source
|
||||
audioRef.current = new Audio(audioUrl);
|
||||
currentAudioUrlRef.current = audioUrl;
|
||||
|
||||
audioRef.current.onended = () => setIsPlaying(false);
|
||||
audioRef.current.onerror = () => {
|
||||
setError('Failed to load this track. Please try another one.');
|
||||
setIsPlaying(false);
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlaying && !needsNewAudio) {
|
||||
// Pause current playback
|
||||
audioRef.current?.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
// Start playback (either new source or resuming)
|
||||
audioRef.current?.play().catch(() => {
|
||||
setError('Failed to play this track. Please try another one.');
|
||||
setIsPlaying(false);
|
||||
});
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [selectedTrack, isPlaying]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!selectedTrack) return;
|
||||
|
||||
// Stop playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
onConfirm(selectedTrack);
|
||||
}, [selectedTrack, onConfirm]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-xl bg-gradient-to-br from-pink-500/20 to-pink-500/5 flex items-center justify-center">
|
||||
<Music className="size-5 text-pink-500" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl">Play Music</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a track to play for your Blobbi
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Track List */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="grid gap-2">
|
||||
{tracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
isSelected={selectedTrack?.track.id === track.id}
|
||||
onSelect={() => handleSelectTrack(track)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t bg-muted/30">
|
||||
{/* Preview Controls */}
|
||||
{selectedTrack && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-card border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={handleTogglePlay}
|
||||
className="size-10 rounded-full shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate text-sm">{selectedTrack.track.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isPlaying ? 'Now playing...' : 'Click to preview'}
|
||||
</p>
|
||||
</div>
|
||||
{isPlaying && (
|
||||
<Volume2 className="size-4 text-primary animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleClose(false)}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedTrack || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Playing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Music className="size-4 mr-2" />
|
||||
Play for Blobbi
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Track Row Component ──────────────────────────────────────────────────────
|
||||
|
||||
interface TrackRowProps {
|
||||
track: BlobbiTrack;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function TrackRow({ track, isSelected, onSelect }: TrackRowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-xl text-left transition-all",
|
||||
"border hover:border-primary/30",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
: "border-border bg-card/60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"size-10 rounded-lg flex items-center justify-center",
|
||||
isSelected ? "bg-primary/20" : "bg-muted"
|
||||
)}>
|
||||
<Music className={cn(
|
||||
"size-5",
|
||||
isSelected ? "text-primary" : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{track.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{track.artist}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatTrackDuration(track.durationSeconds)}
|
||||
</span>
|
||||
{isSelected && <Check className="size-4 text-primary" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
// src/blobbi/actions/components/SingModal.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Mic, MicOff, Play, Pause, Square, Loader2, AlertCircle, RotateCcw, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SingModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'playing' | 'error';
|
||||
|
||||
// ─── MIME Type Selection Helper ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ordered list of MIME types to try for audio recording.
|
||||
* The first supported type will be used.
|
||||
*/
|
||||
const AUDIO_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/ogg',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Get the first supported MIME type for MediaRecorder.
|
||||
* Returns undefined if no explicit MIME type is supported (let browser decide).
|
||||
*/
|
||||
function getSupportedAudioMimeType(): string | undefined {
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const mimeType of AUDIO_MIME_CANDIDATES) {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
// No explicit MIME type supported, let browser use default
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SingModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: SingModalProps) {
|
||||
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Track the actual MIME type used by the recorder
|
||||
const actualMimeTypeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetRecording();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
// Stop timer
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop media recorder
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
|
||||
// Stop stream tracks
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Stop audio playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
// Revoke URL
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
}, [audioUrl]);
|
||||
|
||||
const resetRecording = useCallback(() => {
|
||||
cleanup();
|
||||
setRecordingState('idle');
|
||||
setError(null);
|
||||
setPlaybackError(null);
|
||||
setRecordingDuration(0);
|
||||
setAudioUrl(null);
|
||||
chunksRef.current = [];
|
||||
currentPlaybackUrlRef.current = null;
|
||||
actualMimeTypeRef.current = undefined;
|
||||
// Keep lyrics when re-recording so user can sing the same song
|
||||
}, [cleanup]);
|
||||
|
||||
// Handle getting random lyrics
|
||||
const handleRandomLyrics = useCallback(() => {
|
||||
const lyrics = getRandomLyrics();
|
||||
setCurrentLyrics(lyrics);
|
||||
setShowLyrics(true);
|
||||
}, []);
|
||||
|
||||
// Check if browser supports media recording
|
||||
const checkRecordingSupport = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
if (!navigator.mediaDevices) return false;
|
||||
if (!navigator.mediaDevices.getUserMedia) return false;
|
||||
if (typeof MediaRecorder === 'undefined') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!checkRecordingSupport()) {
|
||||
setError('Audio recording is not supported in this browser.');
|
||||
setRecordingState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingState('requesting');
|
||||
setError(null);
|
||||
setPlaybackError(null);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
}
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
|
||||
// Get the first supported MIME type using our helper
|
||||
const supportedMimeType = getSupportedAudioMimeType();
|
||||
|
||||
// Create MediaRecorder with or without explicit MIME type
|
||||
let mediaRecorder: MediaRecorder;
|
||||
if (supportedMimeType) {
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
|
||||
} else {
|
||||
// Let browser choose default MIME type
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
}
|
||||
|
||||
// Store the actual MIME type being used (may differ from what we requested)
|
||||
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
// Create blob from chunks using the actual MIME type used by the recorder
|
||||
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
|
||||
const blob = new Blob(chunksRef.current, { type: blobMimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
setRecordingState('recorded');
|
||||
|
||||
// Stop stream tracks
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
setError('Recording failed. Please try again.');
|
||||
setRecordingState('error');
|
||||
};
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.start(100); // Collect data every 100ms
|
||||
setRecordingState('recording');
|
||||
setRecordingDuration(0);
|
||||
|
||||
// Start timer
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||
setError('Microphone access was denied. Please allow microphone access and try again.');
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
setError('No microphone found. Please connect a microphone and try again.');
|
||||
} else {
|
||||
setError(`Failed to access microphone: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
setError('Failed to access microphone. Please try again.');
|
||||
}
|
||||
setRecordingState('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Track the current audio URL to detect changes
|
||||
const currentPlaybackUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Play/pause preview
|
||||
const togglePlayback = useCallback(() => {
|
||||
if (!audioUrl) return;
|
||||
|
||||
// Clear previous playback error when attempting to play
|
||||
setPlaybackError(null);
|
||||
|
||||
if (recordingState === 'playing') {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
setRecordingState('recorded');
|
||||
} else {
|
||||
// Check if we need to create a new Audio instance (URL changed or first time)
|
||||
const needsNewAudio = !audioRef.current || currentPlaybackUrlRef.current !== audioUrl;
|
||||
|
||||
if (needsNewAudio) {
|
||||
// Cleanup old audio if exists
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
}
|
||||
|
||||
// Create new Audio instance with the recorded audio URL
|
||||
audioRef.current = new Audio(audioUrl);
|
||||
currentPlaybackUrlRef.current = audioUrl;
|
||||
audioRef.current.onended = () => setRecordingState('recorded');
|
||||
|
||||
// Handle playback errors with user-visible message
|
||||
audioRef.current.onerror = () => {
|
||||
setPlaybackError('This browser could not play the recorded audio preview. Your recording was still created successfully.');
|
||||
setRecordingState('recorded');
|
||||
};
|
||||
}
|
||||
|
||||
audioRef.current?.play()
|
||||
.then(() => {
|
||||
setRecordingState('playing');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to play recording:', err);
|
||||
// Provide user-friendly error message
|
||||
if (err.name === 'NotSupportedError') {
|
||||
setPlaybackError('Recording was created, but playback preview is not supported in this browser.');
|
||||
} else if (err.name === 'NotAllowedError') {
|
||||
setPlaybackError('Playback was blocked. Try interacting with the page first.');
|
||||
} else {
|
||||
setPlaybackError('Could not play the recording preview. Your recording was still created successfully.');
|
||||
}
|
||||
setRecordingState('recorded');
|
||||
});
|
||||
}
|
||||
}, [audioUrl, recordingState]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
onConfirm();
|
||||
}, [onConfirm]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
cleanup();
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange, cleanup]);
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const hasRecording = recordingState === 'recorded' || recordingState === 'playing';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 flex items-center justify-center">
|
||||
<Mic className="size-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl">Sing</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Record yourself singing for your Blobbi
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 px-6 py-8">
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
{/* Recording Visualization */}
|
||||
<div className={cn(
|
||||
"relative size-40 rounded-full flex items-center justify-center transition-all",
|
||||
recordingState === 'recording' && "animate-pulse",
|
||||
recordingState === 'recording'
|
||||
? "bg-red-500/10 ring-4 ring-red-500/30"
|
||||
: hasRecording
|
||||
? "bg-purple-500/10 ring-4 ring-purple-500/30"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
{/* Animated rings for recording */}
|
||||
{recordingState === 'recording' && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-red-500/10 animate-ping" />
|
||||
<div className="absolute inset-4 rounded-full bg-red-500/10 animate-ping animation-delay-150" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className={cn(
|
||||
"relative size-20 rounded-full flex items-center justify-center",
|
||||
recordingState === 'recording'
|
||||
? "bg-red-500 text-white"
|
||||
: hasRecording
|
||||
? "bg-purple-500 text-white"
|
||||
: "bg-muted-foreground/20"
|
||||
)}>
|
||||
{recordingState === 'requesting' ? (
|
||||
<Loader2 className="size-8 animate-spin" />
|
||||
) : recordingState === 'recording' ? (
|
||||
<Mic className="size-8" />
|
||||
) : hasRecording ? (
|
||||
recordingState === 'playing' ? (
|
||||
<Pause className="size-8" />
|
||||
) : (
|
||||
<Play className="size-8 ml-1" />
|
||||
)
|
||||
) : (
|
||||
<MicOff className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration / Status */}
|
||||
<div className="text-center">
|
||||
{recordingState === 'idle' && (
|
||||
<p className="text-muted-foreground">Tap the button below to start recording</p>
|
||||
)}
|
||||
{recordingState === 'requesting' && (
|
||||
<p className="text-muted-foreground">Requesting microphone access...</p>
|
||||
)}
|
||||
{recordingState === 'recording' && (
|
||||
<>
|
||||
<p className="text-3xl font-mono font-bold text-red-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Recording...</p>
|
||||
</>
|
||||
)}
|
||||
{hasRecording && (
|
||||
<>
|
||||
<p className="text-3xl font-mono font-bold text-purple-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{recordingState === 'playing' ? 'Playing...' : 'Tap to preview'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{recordingState === 'error' && (
|
||||
<p className="text-destructive">Recording failed</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="w-full p-3 rounded-lg bg-destructive/10 border border-destructive/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-destructive mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playback Error Message (non-fatal, recording still works) */}
|
||||
{playbackError && (
|
||||
<div className="w-full p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{playbackError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lyrics Helper */}
|
||||
<div className="w-full">
|
||||
{!currentLyrics ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRandomLyrics}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Need lyrics? Get random lyrics
|
||||
</Button>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
className="w-full flex items-center justify-between p-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 text-purple-500" />
|
||||
<span className="font-medium text-sm">{currentLyrics.title}</span>
|
||||
</div>
|
||||
{showLyrics ? (
|
||||
<ChevronUp className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showLyrics && (
|
||||
<div className="px-3 pb-3 pt-0">
|
||||
<div className="p-3 rounded-md bg-muted/50 text-sm leading-relaxed whitespace-pre-line">
|
||||
{currentLyrics.lines.join('\n')}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRandomLyrics}
|
||||
className="w-full mt-2 gap-2 text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Get different lyrics
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recording Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{recordingState === 'idle' || recordingState === 'error' ? (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={startRecording}
|
||||
className="rounded-full px-8 bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
<Mic className="size-5 mr-2" />
|
||||
Start Recording
|
||||
</Button>
|
||||
) : recordingState === 'recording' ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="destructive"
|
||||
onClick={stopRecording}
|
||||
className="rounded-full px-8"
|
||||
>
|
||||
<Square className="size-5 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
) : hasRecording ? (
|
||||
<>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={togglePlayback}
|
||||
className="rounded-full"
|
||||
>
|
||||
{recordingState === 'playing' ? (
|
||||
<>
|
||||
<Pause className="size-5 mr-2" />
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-5 mr-2" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={resetRecording}
|
||||
className="rounded-full"
|
||||
>
|
||||
<RotateCcw className="size-5 mr-2" />
|
||||
Re-record
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t bg-muted/30">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleClose(false)}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!hasRecording || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Singing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="size-4 mr-2" />
|
||||
Sing for Blobbi
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// src/blobbi/actions/components/StartEvolutionDialog.tsx
|
||||
|
||||
/**
|
||||
* Dialog for confirming start of evolution.
|
||||
*
|
||||
* Evolution is simpler than incubation:
|
||||
* - Only baby Blobbis can evolve
|
||||
* - Shows restart confirmation if already evolving
|
||||
* - Otherwise shows normal start confirmation
|
||||
*/
|
||||
|
||||
import { Loader2, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StartEvolutionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The companion to start evolving */
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called when confirmed */
|
||||
onConfirm: () => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function StartEvolutionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: StartEvolutionDialogProps) {
|
||||
// Check if the current Blobbi is already evolving
|
||||
const isAlreadyEvolving = companion?.state === 'evolving';
|
||||
|
||||
// Determine title and description based on state
|
||||
const getDialogContent = () => {
|
||||
if (isAlreadyEvolving) {
|
||||
return {
|
||||
title: 'Restart Evolution?',
|
||||
icon: <AlertTriangle className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
<strong>{companion?.name}</strong> is already evolving. Starting over will{' '}
|
||||
<strong>reset all task progress</strong> and begin from the beginning.
|
||||
<br /><br />
|
||||
Are you sure you want to restart?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Restart Evolution',
|
||||
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Start Evolution',
|
||||
icon: <Sparkles className="size-5 text-primary" />,
|
||||
description: (
|
||||
<>
|
||||
Starting evolution begins <strong>{companion?.name}</strong>'s transformation journey.
|
||||
Complete all the tasks to evolve your baby Blobbi into an adult!
|
||||
<br /><br />
|
||||
Ready to begin?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Start Evolution',
|
||||
buttonClass: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white',
|
||||
};
|
||||
};
|
||||
|
||||
const content = getDialogContent();
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
{content.icon}
|
||||
{content.title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{content.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={content.buttonClass}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
content.buttonText
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// src/blobbi/actions/components/StartIncubationDialog.tsx
|
||||
|
||||
/**
|
||||
* Dialog for confirming start of incubation.
|
||||
*
|
||||
* Determines the mode and passes it explicitly to the confirm callback:
|
||||
* - 'start': Normal start, no other Blobbi incubating
|
||||
* - 'restart': Restart same Blobbi (already incubating)
|
||||
* - 'switch': Stop another Blobbi first, then start this one
|
||||
*
|
||||
* The mode is determined by UI state, NOT auto-detected by the hook.
|
||||
* This makes the flow explicit and predictable.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Loader2, AlertTriangle, ArrowRightLeft } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StartIncubationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The companion to start incubating */
|
||||
companion: BlobbiCompanion | null;
|
||||
/** All companions in the collection (to check for other incubating Blobbis) */
|
||||
companions?: BlobbiCompanion[];
|
||||
/** Called with explicit mode and optional stopOtherD when confirmed */
|
||||
onConfirm: (mode: StartIncubationMode, stopOtherD?: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function StartIncubationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
companions = [],
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: StartIncubationDialogProps) {
|
||||
// Check if the current Blobbi is already in a task state
|
||||
const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving';
|
||||
|
||||
// Check if another Blobbi (not this one) is currently incubating
|
||||
const otherIncubatingBlobbi = useMemo(() => {
|
||||
if (!companion) return null;
|
||||
return companions.find(c =>
|
||||
c.d !== companion.d &&
|
||||
c.state === 'incubating' &&
|
||||
c.stage === 'egg'
|
||||
) ?? null;
|
||||
}, [companion, companions]);
|
||||
|
||||
// Determine the mode based on current state
|
||||
const mode: StartIncubationMode = useMemo(() => {
|
||||
if (isAlreadyInTaskState) return 'restart';
|
||||
if (otherIncubatingBlobbi) return 'switch';
|
||||
return 'start';
|
||||
}, [isAlreadyInTaskState, otherIncubatingBlobbi]);
|
||||
|
||||
// Handle confirm with explicit mode
|
||||
const handleConfirm = () => {
|
||||
if (mode === 'switch' && otherIncubatingBlobbi) {
|
||||
onConfirm(mode, otherIncubatingBlobbi.d);
|
||||
} else {
|
||||
onConfirm(mode);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine title and description based on mode
|
||||
const getDialogContent = () => {
|
||||
if (mode === 'restart') {
|
||||
return {
|
||||
title: 'Restart Incubation?',
|
||||
icon: <AlertTriangle className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
Your Blobbi is already {companion?.state}. Starting over will{' '}
|
||||
<strong>reset all task progress</strong> and begin from the beginning.
|
||||
<br /><br />
|
||||
Are you sure you want to restart?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Restart Incubation',
|
||||
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'switch') {
|
||||
return {
|
||||
title: 'Switch Incubation?',
|
||||
icon: <ArrowRightLeft className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
<strong>{otherIncubatingBlobbi?.name}</strong> is currently incubating.
|
||||
Only one Blobbi can incubate at a time.
|
||||
<br /><br />
|
||||
Starting incubation for <strong>{companion?.name}</strong> will{' '}
|
||||
<strong>stop {otherIncubatingBlobbi?.name}'s incubation</strong> and{' '}
|
||||
reset their task progress.
|
||||
<br /><br />
|
||||
Do you want to switch?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Switch & Start',
|
||||
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Start Incubation',
|
||||
icon: null,
|
||||
description: (
|
||||
<>
|
||||
Starting incubation begins your Blobbi's hatching journey.
|
||||
Complete all the tasks to hatch your egg into a baby Blobbi!
|
||||
<br /><br />
|
||||
Ready to begin?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Start Incubation',
|
||||
buttonClass: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const content = getDialogContent();
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
{content.icon}
|
||||
{content.title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{content.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={content.buttonClass}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
content.buttonText
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// src/blobbi/actions/components/TasksPanel.tsx
|
||||
|
||||
/**
|
||||
* Generic UI component for displaying task progress.
|
||||
* Shows a list of tasks with progress indicators and action buttons.
|
||||
* Used for both hatch and evolve tasks.
|
||||
*/
|
||||
|
||||
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TasksPanelProps {
|
||||
tasks: HatchTask[];
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
/** Called when user clicks "Create Post" action */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all tasks are complete and user clicks the complete button */
|
||||
onComplete: () => void;
|
||||
/** Whether completion is in progress */
|
||||
isCompleting?: boolean;
|
||||
/** Emoji to show in header */
|
||||
emoji: string;
|
||||
/** Title for the tasks panel */
|
||||
title: string;
|
||||
/** Description for the tasks panel */
|
||||
description: string;
|
||||
/** Label for the complete button */
|
||||
completeLabel: string;
|
||||
/** Label while completing */
|
||||
completingLabel: string;
|
||||
/** Emoji for complete button */
|
||||
completeEmoji: string;
|
||||
}
|
||||
|
||||
// ─── Task Row Component ───────────────────────────────────────────────────────
|
||||
|
||||
interface TaskRowProps {
|
||||
task: HatchTask;
|
||||
onOpenPostModal: () => void;
|
||||
}
|
||||
|
||||
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
|
||||
const navigate = useNavigate();
|
||||
const isDynamic = task.type === 'dynamic';
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') {
|
||||
onOpenPostModal();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const progress = task.required > 1
|
||||
? Math.round((task.current / task.required) * 100)
|
||||
: task.completed ? 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border transition-all overflow-hidden",
|
||||
task.completed
|
||||
? "bg-emerald-500/5 border-emerald-500/20"
|
||||
: isDynamic
|
||||
? "bg-amber-500/5 border-amber-500/20"
|
||||
: "bg-card/60 border-border hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Status + Task info */}
|
||||
<div className="flex items-start sm:items-center gap-3 sm:contents">
|
||||
{/* Status indicator */}
|
||||
<div className={cn(
|
||||
"size-8 sm:size-10 rounded-full flex items-center justify-center shrink-0",
|
||||
task.completed
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: isDynamic
|
||||
? "bg-amber-500/20 text-amber-600 dark:text-amber-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{task.completed ? (
|
||||
<Check className="size-4 sm:size-5" />
|
||||
) : isDynamic ? (
|
||||
<AlertCircle className="size-4 sm:size-5" />
|
||||
) : task.required > 1 ? (
|
||||
<span className="text-xs sm:text-sm font-medium">{task.current}/{task.required}</span>
|
||||
) : (
|
||||
<span className="text-base sm:text-lg">○</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mb-0.5 sm:mb-1">
|
||||
<h4 className={cn(
|
||||
"font-medium text-sm sm:text-base break-words",
|
||||
task.completed && "text-emerald-600 dark:text-emerald-400",
|
||||
isDynamic && !task.completed && "text-amber-600 dark:text-amber-400"
|
||||
)}>
|
||||
{task.name}
|
||||
</h4>
|
||||
{task.completed && (
|
||||
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs shrink-0">
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
{isDynamic && !task.completed && (
|
||||
<Badge variant="secondary" className="bg-amber-500/20 text-amber-700 dark:text-amber-300 text-xs shrink-0">
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground break-words">
|
||||
{task.description}
|
||||
</p>
|
||||
|
||||
{/* Progress bar for multi-step tasks (not for dynamic stat tasks) */}
|
||||
{task.required > 1 && !task.completed && !isDynamic && (
|
||||
<Progress value={progress} className="h-1.5 mt-2" />
|
||||
)}
|
||||
|
||||
{/* Dynamic task hint */}
|
||||
{isDynamic && !task.completed && (
|
||||
<p className="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">
|
||||
Lowest stat: {task.current}% (need {task.required}%+)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button - full width on mobile when present */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAction}
|
||||
className="shrink-0 gap-2 w-full sm:w-auto mt-1 sm:mt-0"
|
||||
>
|
||||
<span className="truncate">{task.actionLabel}</span>
|
||||
{task.action === 'external_link' ? (
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function TasksPanel({
|
||||
tasks,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
onOpenPostModal,
|
||||
onComplete,
|
||||
isCompleting = false,
|
||||
emoji,
|
||||
title,
|
||||
description,
|
||||
completeLabel,
|
||||
completingLabel,
|
||||
completeEmoji,
|
||||
}: TasksPanelProps) {
|
||||
const completedCount = tasks.filter(t => t.completed).length;
|
||||
const totalTasks = tasks.length;
|
||||
const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent overflow-hidden">
|
||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
||||
<div className="flex items-start sm:items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<span className="text-xl sm:text-2xl shrink-0">{emoji}</span>
|
||||
<span className="break-words">{title}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm break-words">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm sm:text-base px-2 sm:px-3 py-0.5 sm:py-1 shrink-0">
|
||||
{completedCount}/{totalTasks}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Overall progress */}
|
||||
<div className="mt-3 sm:mt-4">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm mb-2">
|
||||
<span className="text-muted-foreground">Overall progress</span>
|
||||
<span className="font-medium">{overallProgress}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} className="h-2" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2 sm:space-y-3 px-3 sm:px-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tasks.map(task => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Complete button - only visible when all tasks complete */}
|
||||
{allCompleted && (
|
||||
<div className="pt-4 border-t border-border mt-4">
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={isCompleting}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
{isCompleting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{completingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl">{completeEmoji}</span>
|
||||
{completeLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// src/blobbi/actions/hooks/useActiveTaskProcess.ts
|
||||
|
||||
/**
|
||||
* Central abstraction for the active task process (hatch or evolve).
|
||||
*
|
||||
* This hook consolidates all scattered if/else logic for determining:
|
||||
* - Which process is active (incubating vs evolving)
|
||||
* - Which tasks to use (hatch vs evolve)
|
||||
* - Thresholds and configuration
|
||||
* - Badge-related computed values
|
||||
*
|
||||
* ARCHITECTURE RULES:
|
||||
* - Computed tasks remain the source of truth
|
||||
* - Tags are cache only for PERSISTENT tasks
|
||||
* - Dynamic tasks are NEVER persisted
|
||||
* - Badge counts ALL incomplete tasks (persistent + dynamic)
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
|
||||
import type { EvolveTasksResult } from './useEvolveTasks';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The type of task process currently active */
|
||||
export type TaskProcessType = 'hatch' | 'evolve' | null;
|
||||
|
||||
/**
|
||||
* Configuration for the active task process.
|
||||
* This provides a unified interface regardless of whether
|
||||
* the process is hatch or evolve.
|
||||
*/
|
||||
export interface TaskProcessConfig {
|
||||
/** The type of process ('hatch' | 'evolve' | null) */
|
||||
type: TaskProcessType;
|
||||
/** Whether there is an active task process */
|
||||
isActive: boolean;
|
||||
/** Required interactions threshold for the current process */
|
||||
interactionThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the active task process hook.
|
||||
* Provides unified access to all task-related state.
|
||||
*/
|
||||
export interface ActiveTaskProcessResult {
|
||||
/** Configuration for the current process */
|
||||
config: TaskProcessConfig;
|
||||
|
||||
/** All tasks for the current process (empty if no active process) */
|
||||
tasks: HatchTask[];
|
||||
/** Whether tasks are still loading */
|
||||
isLoading: boolean;
|
||||
/** Whether all tasks (persistent + dynamic) are complete */
|
||||
allCompleted: boolean;
|
||||
/** Whether all persistent tasks are complete */
|
||||
persistentTasksComplete: boolean;
|
||||
/** Whether the dynamic task is complete */
|
||||
dynamicTaskComplete: boolean;
|
||||
|
||||
/** Refetch function for current tasks */
|
||||
refetch: () => void;
|
||||
|
||||
// ─── Badge-related computed values ───
|
||||
|
||||
/**
|
||||
* Count of ALL remaining incomplete tasks (persistent + dynamic).
|
||||
* This is used for the badge display.
|
||||
* Dynamic tasks ARE counted here but are NEVER synced to tags.
|
||||
*/
|
||||
remainingTasksCount: number;
|
||||
|
||||
/**
|
||||
* Only persistent tasks that are incomplete.
|
||||
* Used for sync logic - dynamic tasks must NEVER be synced.
|
||||
*/
|
||||
incompletePersistentTasks: HatchTask[];
|
||||
|
||||
/**
|
||||
* Only persistent tasks that are complete.
|
||||
* Used for sync logic.
|
||||
*/
|
||||
completedPersistentTasks: HatchTask[];
|
||||
|
||||
/**
|
||||
* Stable string key of completed persistent task IDs.
|
||||
* Used for sync anti-loop protection.
|
||||
*/
|
||||
completedPersistentTaskIds: string;
|
||||
|
||||
/**
|
||||
* Tasks to sync (persistent only, with completion status).
|
||||
* Dynamic tasks are excluded.
|
||||
*/
|
||||
tasksToSync: Array<{ taskId: string; completed: boolean }>;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter tasks to only persistent tasks.
|
||||
* Dynamic tasks must NEVER be synced to tags.
|
||||
*/
|
||||
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
|
||||
return tasks.filter(t => t.type === 'persistent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tasks to only dynamic tasks.
|
||||
*/
|
||||
export function filterDynamicTasks(tasks: HatchTask[]): HatchTask[] {
|
||||
return tasks.filter(t => t.type === 'dynamic');
|
||||
}
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook that provides a unified interface for the active task process.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const taskProcess = useActiveTaskProcess(companion, hatchTasks, evolveTasks);
|
||||
*
|
||||
* // Access unified data
|
||||
* taskProcess.config.type // 'hatch' | 'evolve' | null
|
||||
* taskProcess.tasks // current tasks
|
||||
* taskProcess.remainingTasksCount // for badge (includes dynamic)
|
||||
* taskProcess.tasksToSync // for sync (excludes dynamic)
|
||||
* ```
|
||||
*/
|
||||
export function useActiveTaskProcess(
|
||||
companion: BlobbiCompanion | null,
|
||||
hatchTasks: HatchTasksResult,
|
||||
evolveTasks: EvolveTasksResult
|
||||
): ActiveTaskProcessResult {
|
||||
// Determine which process is active
|
||||
const processType = useMemo((): TaskProcessType => {
|
||||
if (!companion) return null;
|
||||
if (companion.state === 'incubating') return 'hatch';
|
||||
if (companion.state === 'evolving') return 'evolve';
|
||||
return null;
|
||||
}, [companion]);
|
||||
|
||||
// Build configuration
|
||||
const config = useMemo((): TaskProcessConfig => {
|
||||
const isActive = processType !== null;
|
||||
const interactionThreshold = processType === 'hatch'
|
||||
? HATCH_REQUIRED_INTERACTIONS
|
||||
: processType === 'evolve'
|
||||
? EVOLVE_REQUIRED_INTERACTIONS
|
||||
: 0;
|
||||
|
||||
return {
|
||||
type: processType,
|
||||
isActive,
|
||||
interactionThreshold,
|
||||
};
|
||||
}, [processType]);
|
||||
|
||||
// Get the active tasks result based on process type
|
||||
const activeResult = useMemo(() => {
|
||||
if (processType === 'hatch') return hatchTasks;
|
||||
if (processType === 'evolve') return evolveTasks;
|
||||
return null;
|
||||
}, [processType, hatchTasks, evolveTasks]);
|
||||
|
||||
// Extract tasks and state from active result
|
||||
const tasks = activeResult?.tasks ?? [];
|
||||
const isLoading = activeResult?.isLoading ?? false;
|
||||
const allCompleted = activeResult?.allCompleted ?? false;
|
||||
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
|
||||
const dynamicTaskComplete = activeResult?.dynamicTaskComplete ?? false;
|
||||
const refetch = activeResult?.refetch ?? (() => {});
|
||||
|
||||
// Compute persistent task list (dynamic tasks computed for badge count directly from tasks array)
|
||||
const persistentTasks = useMemo(() => filterPersistentTasks(tasks), [tasks]);
|
||||
|
||||
// Compute incomplete tasks (for badge - includes BOTH persistent and dynamic)
|
||||
const remainingTasksCount = useMemo(() => {
|
||||
// Count ALL incomplete tasks - persistent AND dynamic
|
||||
// Dynamic tasks are included in badge count but NEVER synced to tags
|
||||
return tasks.filter(t => !t.completed).length;
|
||||
}, [tasks]);
|
||||
|
||||
// Compute persistent task lists for sync
|
||||
const incompletePersistentTasks = useMemo(() =>
|
||||
persistentTasks.filter(t => !t.completed),
|
||||
[persistentTasks]
|
||||
);
|
||||
|
||||
const completedPersistentTasks = useMemo(() =>
|
||||
persistentTasks.filter(t => t.completed),
|
||||
[persistentTasks]
|
||||
);
|
||||
|
||||
// Compute stable string key for completed persistent tasks (anti-loop)
|
||||
const completedPersistentTaskIds = useMemo(() => {
|
||||
if (!completedPersistentTasks.length) return '';
|
||||
return completedPersistentTasks
|
||||
.map(t => t.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
}, [completedPersistentTasks]);
|
||||
|
||||
// Compute tasks to sync (persistent only)
|
||||
// CRITICAL: Dynamic tasks must NEVER be included here
|
||||
const tasksToSync = useMemo(() => {
|
||||
if (!persistentTasks.length) return [];
|
||||
return persistentTasks.map(t => ({
|
||||
taskId: t.id,
|
||||
completed: t.completed,
|
||||
}));
|
||||
}, [persistentTasks]);
|
||||
|
||||
return {
|
||||
config,
|
||||
tasks,
|
||||
isLoading,
|
||||
allCompleted,
|
||||
persistentTasksComplete,
|
||||
dynamicTaskComplete,
|
||||
refetch,
|
||||
remainingTasksCount,
|
||||
incompletePersistentTasks,
|
||||
completedPersistentTasks,
|
||||
completedPersistentTaskIds,
|
||||
tasksToSync,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// src/blobbi/actions/hooks/useAudioPlayback.ts
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Audio playback state
|
||||
* - idle: No audio loaded
|
||||
* - loading: Audio is being loaded
|
||||
* - playing: Audio is playing
|
||||
* - paused: Audio is paused (can resume)
|
||||
* - stopped: Audio was stopped (must reload to play again)
|
||||
* - error: An error occurred
|
||||
*/
|
||||
export type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error';
|
||||
|
||||
/**
|
||||
* Audio playback error info
|
||||
*/
|
||||
export interface PlaybackError {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** Default volume level (0-1) */
|
||||
const DEFAULT_VOLUME = 0.8;
|
||||
|
||||
/**
|
||||
* Options for the useAudioPlayback hook
|
||||
*/
|
||||
export interface UseAudioPlaybackOptions {
|
||||
/** Called when playback ends naturally */
|
||||
onEnded?: () => void;
|
||||
/** Called when an error occurs */
|
||||
onError?: (error: PlaybackError) => void;
|
||||
/** Initial volume level (0-1), defaults to 0.8 */
|
||||
initialVolume?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useAudioPlayback hook
|
||||
*/
|
||||
export interface UseAudioPlaybackReturn {
|
||||
/** Current playback state */
|
||||
state: PlaybackState;
|
||||
/** Current error (if any) */
|
||||
error: PlaybackError | null;
|
||||
/** Current audio URL being played */
|
||||
currentUrl: string | null;
|
||||
/** Load and optionally start playing an audio URL */
|
||||
load: (url: string, autoplay?: boolean) => void;
|
||||
/** Play the current audio */
|
||||
play: () => Promise<void>;
|
||||
/** Pause the current audio */
|
||||
pause: () => void;
|
||||
/** Stop playback and reset */
|
||||
stop: () => void;
|
||||
/** Restart playback from the beginning */
|
||||
restart: () => Promise<void>;
|
||||
/** Toggle play/pause */
|
||||
toggle: () => Promise<void>;
|
||||
/** Whether audio is currently playing */
|
||||
isPlaying: boolean;
|
||||
/** Current volume level (0-1) */
|
||||
volume: number;
|
||||
/** Set volume level (0-1) */
|
||||
setVolume: (volume: number) => void;
|
||||
/** Cleanup function to release resources */
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable hook for audio playback.
|
||||
* Handles Audio element lifecycle, error handling, and state management.
|
||||
*/
|
||||
export function useAudioPlayback(options: UseAudioPlaybackOptions = {}): UseAudioPlaybackReturn {
|
||||
const { onEnded, onError, initialVolume = DEFAULT_VOLUME } = options;
|
||||
|
||||
const [state, setState] = useState<PlaybackState>('idle');
|
||||
const [error, setError] = useState<PlaybackError | null>(null);
|
||||
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
|
||||
const [volume, setVolumeState] = useState<number>(initialVolume);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const currentUrlRef = useRef<string | null>(null);
|
||||
const volumeRef = useRef<number>(initialVolume);
|
||||
|
||||
// Cleanup audio element
|
||||
const cleanup = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
audioRef.current.oncanplay = null;
|
||||
audioRef.current.onplaying = null;
|
||||
audioRef.current = null;
|
||||
}
|
||||
currentUrlRef.current = null;
|
||||
setState('idle');
|
||||
setCurrentUrl(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
// Load audio from URL
|
||||
const load = useCallback((url: string, autoplay = false) => {
|
||||
// If same URL, don't reload
|
||||
if (currentUrlRef.current === url && audioRef.current) {
|
||||
if (autoplay) {
|
||||
audioRef.current.play().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup previous audio
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
audioRef.current.oncanplay = null;
|
||||
audioRef.current.onplaying = null;
|
||||
}
|
||||
|
||||
setState('loading');
|
||||
setError(null);
|
||||
setCurrentUrl(url);
|
||||
currentUrlRef.current = url;
|
||||
|
||||
const audio = new Audio(url);
|
||||
audio.volume = volumeRef.current; // Apply current volume to new audio
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.oncanplay = () => {
|
||||
if (autoplay) {
|
||||
audio.play().catch((err) => {
|
||||
const playbackError: PlaybackError = {
|
||||
message: getPlaybackErrorMessage(err),
|
||||
code: err.name,
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
});
|
||||
} else {
|
||||
setState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
audio.onplaying = () => {
|
||||
setState('playing');
|
||||
};
|
||||
|
||||
audio.onpause = () => {
|
||||
if (state === 'playing') {
|
||||
setState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
audio.onended = () => {
|
||||
setState('paused');
|
||||
onEnded?.();
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
const playbackError: PlaybackError = {
|
||||
message: 'Failed to load audio. The format may not be supported.',
|
||||
code: 'MEDIA_ERR',
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
};
|
||||
|
||||
// Start loading
|
||||
audio.load();
|
||||
}, [onEnded, onError, state]);
|
||||
|
||||
// Play current audio
|
||||
const play = useCallback(async () => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await audioRef.current.play();
|
||||
setState('playing');
|
||||
} catch (err) {
|
||||
const playbackError: PlaybackError = {
|
||||
message: getPlaybackErrorMessage(err),
|
||||
code: err instanceof Error ? err.name : 'UNKNOWN',
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
// Pause current audio
|
||||
const pause = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.pause();
|
||||
setState('paused');
|
||||
}, []);
|
||||
|
||||
// Stop playback completely (requires reload to play again)
|
||||
const stop = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
// Clear URL ref so next load() will actually reload
|
||||
currentUrlRef.current = null;
|
||||
setState('stopped');
|
||||
}, []);
|
||||
|
||||
// Restart playback from the beginning
|
||||
const restart = useCallback(async () => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.currentTime = 0;
|
||||
try {
|
||||
await audioRef.current.play();
|
||||
setState('playing');
|
||||
} catch (err) {
|
||||
const playbackError: PlaybackError = {
|
||||
message: getPlaybackErrorMessage(err),
|
||||
code: err instanceof Error ? err.name : 'UNKNOWN',
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
// Toggle play/pause
|
||||
const toggle = useCallback(async () => {
|
||||
if (state === 'playing') {
|
||||
pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}, [state, play, pause]);
|
||||
|
||||
// Set volume (0-1)
|
||||
const setVolume = useCallback((newVolume: number) => {
|
||||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
||||
volumeRef.current = clampedVolume;
|
||||
setVolumeState(clampedVolume);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = clampedVolume;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
error,
|
||||
currentUrl,
|
||||
load,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
restart,
|
||||
toggle,
|
||||
isPlaying: state === 'playing',
|
||||
volume,
|
||||
setVolume,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message for playback errors
|
||||
*/
|
||||
function getPlaybackErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotSupportedError') {
|
||||
return 'This audio format is not supported by your browser.';
|
||||
}
|
||||
if (err.name === 'NotAllowedError') {
|
||||
return 'Playback was blocked. Try interacting with the page first.';
|
||||
}
|
||||
return err.message;
|
||||
}
|
||||
return 'An unknown error occurred during playback.';
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* useBlobbiCareActivity - Hook for registering care activity and updating streaks
|
||||
*
|
||||
* This hook provides a centralized way to register care activity for a Blobbi companion.
|
||||
* It handles:
|
||||
* - Calculating streak updates based on the last activity day
|
||||
* - Publishing updated Blobbi state to Nostr
|
||||
* - Updating local cache
|
||||
*
|
||||
* Use this hook whenever care activity should count toward the streak:
|
||||
* - Opening the Blobbi page (page check-in)
|
||||
* - Performing care actions (feed, clean, play, etc.)
|
||||
* - Any other care interaction
|
||||
*
|
||||
* The streak only increments once per calendar day, regardless of how many
|
||||
* activities are performed.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseBlobbiCareActivityParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
export interface CareActivityResult {
|
||||
/** Whether the streak was updated */
|
||||
wasUpdated: boolean;
|
||||
/** The new streak value */
|
||||
newStreak: number;
|
||||
/** Description of what happened */
|
||||
action: StreakUpdateResult['action'];
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to register care activity and update streaks.
|
||||
*
|
||||
* Returns a function to register activity and a mutation for the actual update.
|
||||
* The register function is idempotent - calling it multiple times on the same day
|
||||
* will only update once.
|
||||
*/
|
||||
export function useBlobbiCareActivity({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiCareActivityParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Track if we've already registered activity this session to avoid duplicate calls
|
||||
// This is a performance optimization - the actual idempotency is handled by day comparison
|
||||
const lastRegisteredDay = useRef<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (): Promise<CareActivityResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to register care activity');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion available');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Calculate what the streak update should be
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
// If no update needed (same day), return early without publishing
|
||||
if (!result.wasUpdated) {
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: result.newStreak,
|
||||
action: result.action,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the tag updates
|
||||
const streakUpdates = getStreakTagUpdates(companion, now);
|
||||
|
||||
if (!streakUpdates) {
|
||||
// Shouldn't happen if wasUpdated is true, but handle gracefully
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: companion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Build updated tags
|
||||
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
|
||||
|
||||
// Publish the updated event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update local cache
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// Update session tracker
|
||||
lastRegisteredDay.current = result.newLastDay;
|
||||
|
||||
// Log for debugging (dev only)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CareActivity] Streak updated:', {
|
||||
action: result.action,
|
||||
previousStreak: companion.careStreak,
|
||||
newStreak: result.newStreak,
|
||||
lastDay: companion.careStreakLastDay,
|
||||
newDay: result.newLastDay,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: result.newStreak,
|
||||
action: result.action,
|
||||
};
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.wasUpdated) {
|
||||
invalidateCompanion();
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('[CareActivity] Failed to update streak:', error);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Register care activity. Call this when care-related activity happens.
|
||||
* Safe to call multiple times - only updates streak once per day.
|
||||
*
|
||||
* @returns Promise with the result of the activity registration
|
||||
*/
|
||||
const registerCareActivity = useCallback(async (): Promise<CareActivityResult | null> => {
|
||||
if (!companion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Quick check if we've already registered for this companion's last day (session cache)
|
||||
// This is an optimization to avoid unnecessary mutation calls
|
||||
if (lastRegisteredDay.current === companion.careStreakLastDay) {
|
||||
// Already processed this day in this session, skip
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: companion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
return mutation.mutateAsync();
|
||||
}, [companion, mutation]);
|
||||
|
||||
return {
|
||||
/** Register care activity - call when page opens or care action happens */
|
||||
registerCareActivity,
|
||||
/** Whether an update is currently in progress */
|
||||
isUpdating: mutation.isPending,
|
||||
/** The last update result */
|
||||
lastResult: mutation.data,
|
||||
/** Any error from the last update attempt */
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiDirectAction.ts
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import {
|
||||
clampStat,
|
||||
applyStat,
|
||||
DIRECT_ACTION_METADATA,
|
||||
incrementInteractionTaskTags,
|
||||
type DirectAction,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
// Import NostrEvent type
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Configuration for direct action happiness effects.
|
||||
* These are the happiness deltas for each direct action.
|
||||
*/
|
||||
export const DIRECT_ACTION_HAPPINESS_EFFECTS: Record<DirectAction, number> = {
|
||||
play_music: 15,
|
||||
sing: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* Request payload for executing a direct action
|
||||
*/
|
||||
export interface DirectActionRequest {
|
||||
action: DirectAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of executing a direct action
|
||||
*/
|
||||
export interface DirectActionResult {
|
||||
action: DirectAction;
|
||||
happinessChange: number;
|
||||
xpGained: number;
|
||||
newXP: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the useBlobbiDirectAction hook
|
||||
*/
|
||||
export interface UseBlobbiDirectActionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called after ensuring companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration happened) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to execute a direct action on a Blobbi companion.
|
||||
* Direct actions (play_music, sing) don't consume inventory items.
|
||||
* They directly affect happiness stat.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Validates the companion exists
|
||||
* 2. Ensures canonical format before action
|
||||
* 3. Applies accumulated decay
|
||||
* 4. Applies happiness boost
|
||||
* 5. Updates Blobbi state (kind 31124)
|
||||
* 6. Invalidates relevant queries
|
||||
*/
|
||||
export function useBlobbiDirectAction({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiDirectActionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ action }: DirectActionRequest): Promise<DirectActionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to perform actions');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for action');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Apply Happiness Effect ───
|
||||
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
|
||||
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
|
||||
|
||||
// Track if happiness actually changed
|
||||
const happinessChanged = newHappiness !== statsAfterDecay.happiness;
|
||||
|
||||
// Build stats update
|
||||
const isEgg = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {
|
||||
happiness: newHappiness.toString(),
|
||||
health: statsAfterDecay.health.toString(),
|
||||
hygiene: statsAfterDecay.hygiene.toString(),
|
||||
};
|
||||
|
||||
if (isEgg) {
|
||||
// Eggs have fixed hunger and energy
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
statsUpdate.hunger = clampStat(statsAfterDecay.hunger).toString();
|
||||
statsUpdate.energy = clampStat(statsAfterDecay.energy).toString();
|
||||
}
|
||||
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// If incubating or evolving, increment the interaction counter for tasks
|
||||
const companionState = canonical.companion.state;
|
||||
let updatedTags = canonical.allTags;
|
||||
if (companionState === 'incubating') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
|
||||
} else if (companionState === 'evolving') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
|
||||
}
|
||||
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain (ONLY if happiness actually changed) ───
|
||||
// Direct actions modify happiness. Only grant XP if happiness actually increased.
|
||||
const xpGained = happinessChanged ? calculateActionXP(action) : 0;
|
||||
const currentXP = canonical.companion.experience ?? 0;
|
||||
const newXP = applyXPGain(currentXP, xpGained);
|
||||
|
||||
const blobbiTags = updateBlobbiTags(updatedTags, {
|
||||
...statsUpdate,
|
||||
...streakUpdates,
|
||||
experience: newXP.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const blobbiEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: blobbiTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
happinessChange: happinessDelta,
|
||||
xpGained,
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ action, happinessChange, xpGained }) => {
|
||||
const actionMeta = DIRECT_ACTION_METADATA[action];
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
title: `${actionMeta.label} complete!`,
|
||||
description: `Your Blobbi's happiness increased by ${happinessChange}! ${xpText}`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
// 'interact' is always tracked, plus the specific action
|
||||
const dailyActions: DailyMissionAction[] = ['interact'];
|
||||
if (action === 'sing') dailyActions.push('sing');
|
||||
if (action === 'play_music') dailyActions.push('play_music');
|
||||
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Action failed',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiIncubation.ts
|
||||
|
||||
/**
|
||||
* Hooks for Blobbi incubation task system.
|
||||
*
|
||||
* When a user starts incubation:
|
||||
* 1. Apply accumulated decay from last_decay_at to now
|
||||
* 2. Set state to 'incubating'
|
||||
* 3. Add state_started_at timestamp
|
||||
* 4. Update last_decay_at to the same timestamp
|
||||
* 5. Clear any previous task progress
|
||||
*
|
||||
* Tasks are computed from Nostr events with created_at >= state_started_at
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mode for starting incubation.
|
||||
* This makes the intent explicit rather than auto-detecting behavior.
|
||||
*/
|
||||
export type StartIncubationMode =
|
||||
| 'start' // Normal start (no other Blobbi incubating)
|
||||
| 'restart' // Restart same Blobbi (already incubating)
|
||||
| 'switch'; // Switch from another incubating Blobbi
|
||||
|
||||
/**
|
||||
* Request to start incubation with explicit mode.
|
||||
*/
|
||||
export interface StartIncubationRequest {
|
||||
/** Explicit mode for this operation */
|
||||
mode: StartIncubationMode;
|
||||
/** The d-tag of the other Blobbi to stop (required when mode === 'switch') */
|
||||
stopOtherD?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for start incubation hook.
|
||||
*/
|
||||
export interface UseStartIncubationParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of starting incubation.
|
||||
*/
|
||||
export interface StartIncubationResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
/** Timestamp when incubation started */
|
||||
stateStartedAt: number;
|
||||
/** Mode that was used */
|
||||
mode: StartIncubationMode;
|
||||
/** Name of other Blobbi that was stopped (if mode === 'switch') */
|
||||
stoppedOtherName?: string;
|
||||
}
|
||||
|
||||
// ─── Start Incubation Hook ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to start the incubation process for an egg.
|
||||
*
|
||||
* This sets the Blobbi state to 'incubating' and records the start timestamp.
|
||||
* Tasks will be computed based on events created after this timestamp.
|
||||
*
|
||||
* IMPORTANT: The mode must be explicitly specified by the caller (UI).
|
||||
* This hook does NOT auto-detect whether to switch or restart.
|
||||
* The UI dialog determines the mode and passes it explicitly.
|
||||
*
|
||||
* Modes:
|
||||
* - 'start': Normal start, no other Blobbi incubating
|
||||
* - 'restart': Restart same Blobbi (already incubating), resets task progress
|
||||
* - 'switch': Stop another Blobbi first, then start this one
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in egg stage
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStartIncubation({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (request: StartIncubationRequest): Promise<StartIncubationResult> => {
|
||||
const { mode, stopOtherD } = request;
|
||||
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to start incubation');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'egg') {
|
||||
throw new Error('Only eggs can be incubated');
|
||||
}
|
||||
|
||||
// Validate switch mode requires stopOtherD
|
||||
if (mode === 'switch' && !stopOtherD) {
|
||||
throw new Error('Switch mode requires stopOtherD parameter');
|
||||
}
|
||||
|
||||
let stoppedOtherName: string | undefined;
|
||||
|
||||
// ─── Stop Other Incubating Blobbi (switch mode only) ───
|
||||
if (mode === 'switch' && stopOtherD) {
|
||||
// Fetch the current event for the other Blobbi
|
||||
const [otherEvent] = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [stopOtherD],
|
||||
limit: 1,
|
||||
}]);
|
||||
|
||||
if (otherEvent) {
|
||||
// Get name from the event for the result
|
||||
const nameTag = otherEvent.tags.find(t => t[0] === 'name');
|
||||
stoppedOtherName = nameTag?.[1] ?? stopOtherD;
|
||||
|
||||
// Stop the other Blobbi's incubation
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Parse stats from the event
|
||||
const getTagValue = (tags: string[][], name: string): number =>
|
||||
parseInt(tags.find(t => t[0] === name)?.[1] ?? '50', 10);
|
||||
|
||||
const otherStats = {
|
||||
hunger: getTagValue(otherEvent.tags, 'hunger'),
|
||||
happiness: getTagValue(otherEvent.tags, 'happiness'),
|
||||
health: getTagValue(otherEvent.tags, 'health'),
|
||||
hygiene: getTagValue(otherEvent.tags, 'hygiene'),
|
||||
energy: getTagValue(otherEvent.tags, 'energy'),
|
||||
};
|
||||
const otherLastDecayAt = getTagValue(otherEvent.tags, 'last_decay_at') || now;
|
||||
|
||||
// Apply decay to the other Blobbi
|
||||
const otherDecayResult = applyBlobbiDecay({
|
||||
stage: 'egg',
|
||||
state: 'incubating',
|
||||
stats: otherStats,
|
||||
lastDecayAt: otherLastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// Remove task tags and state_started_at from the other Blobbi
|
||||
const otherCleanedTags = otherEvent.tags.filter(tag =>
|
||||
tag[0] !== 'task' &&
|
||||
tag[0] !== 'task_completed' &&
|
||||
tag[0] !== 'state_started_at'
|
||||
);
|
||||
|
||||
const otherNewTags = updateBlobbiTags(otherCleanedTags, {
|
||||
health: otherDecayResult.stats.health.toString(),
|
||||
hygiene: otherDecayResult.stats.hygiene.toString(),
|
||||
happiness: otherDecayResult.stats.happiness.toString(),
|
||||
hunger: '100',
|
||||
energy: '100',
|
||||
state: 'active',
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// Publish the stop event for the other Blobbi
|
||||
const stopEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: otherEvent.content,
|
||||
tags: otherNewTags,
|
||||
});
|
||||
|
||||
// Update the cache for the stopped Blobbi
|
||||
updateCompanionEvent(stopEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for incubation');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
// CRITICAL: Apply decay from last_decay_at to now before changing state
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove any existing task tags when starting fresh (for all modes)
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' && tag[0] !== 'task_completed'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
// Eggs have fixed hunger and energy at 100
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: '100',
|
||||
energy: '100',
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'incubating',
|
||||
state_started_at: nowStr,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
stateStartedAt: now,
|
||||
mode,
|
||||
stoppedOtherName,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name, mode, stoppedOtherName }) => {
|
||||
if (mode === 'switch' && stoppedOtherName) {
|
||||
toast({
|
||||
title: 'Switched incubation!',
|
||||
description: `Stopped ${stoppedOtherName}, now incubating ${name}.`,
|
||||
});
|
||||
} else if (mode === 'restart') {
|
||||
toast({
|
||||
title: 'Incubation restarted!',
|
||||
description: `${name}'s task progress has been reset.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Incubation started!',
|
||||
description: `${name} is now incubating. Complete the tasks to hatch!`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to start incubation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Stop Incubation Hook ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parameters for stop incubation hook.
|
||||
*/
|
||||
export interface UseStopIncubationParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of stopping incubation.
|
||||
*/
|
||||
export interface StopIncubationResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to stop/cancel the incubation process for a Blobbi.
|
||||
*
|
||||
* This resets the Blobbi state to 'active' and clears all task progress tags.
|
||||
* The user can restart incubation later, but will need to complete tasks again.
|
||||
*
|
||||
* When stopping incubation:
|
||||
* - Apply accumulated decay first
|
||||
* - Set state back to 'active'
|
||||
* - Remove state_started_at tag
|
||||
* - Remove all task and task_completed tags
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in incubating state
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStopIncubation({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StopIncubationResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to stop incubation');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (companion.state !== 'incubating') {
|
||||
throw new Error('This Blobbi is not incubating');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove task tags and state_started_at
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' &&
|
||||
tag[0] !== 'task_completed' &&
|
||||
tag[0] !== 'state_started_at'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
// Eggs have fixed hunger and energy at 100
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: '100',
|
||||
energy: '100',
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'active',
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Incubation stopped',
|
||||
description: `${name} is no longer incubating. Task progress has been reset.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to stop incubation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Start Evolution Hook ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parameters for start evolution hook.
|
||||
*/
|
||||
export interface UseStartEvolutionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of starting evolution.
|
||||
*/
|
||||
export interface StartEvolutionResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
/** Timestamp when evolution started */
|
||||
stateStartedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to start the evolution process for a baby Blobbi.
|
||||
*
|
||||
* This sets the Blobbi state to 'evolving' and records the start timestamp.
|
||||
* Tasks will be computed based on events created after this timestamp.
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in baby stage
|
||||
* - Blobbi must not already be evolving
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStartEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StartEvolutionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to start evolution');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'baby') {
|
||||
throw new Error('Only baby Blobbis can evolve');
|
||||
}
|
||||
|
||||
if (companion.state === 'evolving') {
|
||||
throw new Error('This Blobbi is already evolving');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for evolution');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove any existing task tags when starting fresh
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' && tag[0] !== 'task_completed'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: decayResult.stats.hunger.toString(),
|
||||
energy: decayResult.stats.energy.toString(),
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'evolving',
|
||||
state_started_at: nowStr,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
stateStartedAt: now,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Evolution started!',
|
||||
description: `${name} is now working towards evolution. Complete the tasks to evolve!`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to start evolution',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Stop Evolution Hook ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parameters for stop evolution hook.
|
||||
*/
|
||||
export interface UseStopEvolutionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of stopping evolution.
|
||||
*/
|
||||
export interface StopEvolutionResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to stop/cancel the evolution process for a Blobbi.
|
||||
*
|
||||
* This resets the Blobbi state to 'active' and clears all task progress tags.
|
||||
* The user can restart evolution later, but will need to complete tasks again.
|
||||
*
|
||||
* When stopping evolution:
|
||||
* - Apply accumulated decay first
|
||||
* - Set state back to 'active'
|
||||
* - Remove state_started_at tag
|
||||
* - Remove all task and task_completed tags
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in evolving state
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStopEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StopEvolutionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to stop evolution');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (companion.state !== 'evolving') {
|
||||
throw new Error('This Blobbi is not evolving');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove task tags and state_started_at
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' &&
|
||||
tag[0] !== 'task_completed' &&
|
||||
tag[0] !== 'state_started_at'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: decayResult.stats.hunger.toString(),
|
||||
energy: decayResult.stats.energy.toString(),
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'active',
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Evolution stopped',
|
||||
description: `${name} is no longer evolving. Task progress has been reset.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to stop evolution',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sync Task Completions Hook ───────────────────────────────────────────────
|
||||
|
||||
/** Enable debug logging in development only */
|
||||
const DEBUG_TASK_SYNC = import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* Parameters for syncing task completions (works for both hatch and evolve).
|
||||
*/
|
||||
export interface UseSyncTaskCompletionsParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task completions to sync (from useHatchTasks or useEvolveTasks).
|
||||
*/
|
||||
export interface TaskCompletionToSync {
|
||||
taskId: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of sync operation.
|
||||
*/
|
||||
export interface SyncTaskCompletionsResult {
|
||||
/** Task IDs that were synced (empty if nothing needed) */
|
||||
synced: string[];
|
||||
/** Whether sync was skipped (no diff) */
|
||||
skipped: boolean;
|
||||
/** Reason for skip (for debugging) */
|
||||
skipReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to sync persistent task completions to kind 31124 tags.
|
||||
* Works for both hatch (incubating) and evolve (evolving) processes.
|
||||
*
|
||||
* CRITICAL: This is a cache-only sync. It must be:
|
||||
* 1. Fully idempotent - calling multiple times with same data = no-op
|
||||
* 2. Diff-based - only publish when tags would actually change
|
||||
* 3. Safe - no last_interaction update (this is cache sync, not user action)
|
||||
* 4. Only sync PERSISTENT tasks - dynamic tasks must NEVER be synced
|
||||
*
|
||||
* Source of truth = computed task state from Nostr events.
|
||||
* Tags = cache layer for faster access.
|
||||
*/
|
||||
export function useSyncTaskCompletions({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseSyncTaskCompletionsParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tasksToSync: TaskCompletionToSync[]): Promise<SyncTaskCompletionsResult> => {
|
||||
// ─── Early Guards ───
|
||||
if (!user?.pubkey) {
|
||||
return { synced: [], skipped: true, skipReason: 'no_user' };
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
return { synced: [], skipped: true, skipReason: 'no_companion' };
|
||||
}
|
||||
|
||||
// Must be in an active task process (incubating or evolving)
|
||||
if (companion.state !== 'incubating' && companion.state !== 'evolving') {
|
||||
return { synced: [], skipped: true, skipReason: 'not_in_task_process' };
|
||||
}
|
||||
|
||||
// ─── Compute Diff ───
|
||||
// Get cached completions from companion.tasksCompleted (parsed from tags)
|
||||
const cachedCompletions = new Set(companion.tasksCompleted);
|
||||
|
||||
// Get computed completions from tasks (works for both hatch and evolve)
|
||||
const computedCompletions = tasksToSync
|
||||
.filter(t => t.completed)
|
||||
.map(t => t.taskId);
|
||||
|
||||
// Find tasks that are computed as complete but NOT in cache
|
||||
const missingFromCache = computedCompletions.filter(id => !cachedCompletions.has(id));
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Diff check:', {
|
||||
cachedCompletions: Array.from(cachedCompletions),
|
||||
computedCompletions,
|
||||
missingFromCache,
|
||||
});
|
||||
}
|
||||
|
||||
// If no diff, skip entirely
|
||||
if (missingFromCache.length === 0) {
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Skipped: no diff between computed and cached');
|
||||
}
|
||||
return { synced: [], skipped: true, skipReason: 'no_diff' };
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
return { synced: [], skipped: true, skipReason: 'canonical_failed' };
|
||||
}
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Re-check against canonical.allTags (may have updated since companion was parsed)
|
||||
const existingCompletionTags = new Set(
|
||||
canonical.allTags
|
||||
.filter(tag => tag[0] === 'task_completed')
|
||||
.map(tag => tag[1])
|
||||
);
|
||||
|
||||
// Filter to only truly missing tags
|
||||
const tagsToAdd = missingFromCache.filter(id => !existingCompletionTags.has(id));
|
||||
|
||||
if (tagsToAdd.length === 0) {
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Skipped: all tags already exist in canonical');
|
||||
}
|
||||
return { synced: [], skipped: true, skipReason: 'tags_already_exist' };
|
||||
}
|
||||
|
||||
// Add only the missing task_completed tags
|
||||
// CRITICAL: Do NOT update last_interaction - this is cache sync, not user action
|
||||
const updatedTags = [
|
||||
...canonical.allTags,
|
||||
...tagsToAdd.map(id => ['task_completed', id]),
|
||||
];
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Publishing:', {
|
||||
tagsToAdd,
|
||||
totalTags: updatedTags.length,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Publish ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Published successfully:', tagsToAdd);
|
||||
}
|
||||
|
||||
return { synced: tagsToAdd, skipped: false };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiStageTransition.ts
|
||||
|
||||
/**
|
||||
* Hooks for Blobbi stage transitions (hatch, evolve).
|
||||
*
|
||||
* Both transitions follow the same decay pattern:
|
||||
* 1. Apply accumulated decay from `last_decay_at` to `now`
|
||||
* 2. Use decayed stats as the source of truth for the transition
|
||||
* 3. Publish new event with decayed stats + new stage
|
||||
* 4. Reset `last_decay_at` to current timestamp
|
||||
*
|
||||
* @see docs/blobbi/decay-system.md
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
STAT_MAX,
|
||||
updateBlobbiTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
|
||||
// ─── Content Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate the content string for a Blobbi at a given stage.
|
||||
* Format: "{name} is a {stage} Blobbi."
|
||||
*
|
||||
* Uses correct grammar: "an egg" vs "a baby/adult"
|
||||
*/
|
||||
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
const article = stage === 'egg' ? 'an' : 'a';
|
||||
return `${name} is ${article} ${stage} Blobbi.`;
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of ensuring canonical companion before action.
|
||||
* This is the same interface used by useBlobbiUseInventoryItem.
|
||||
*/
|
||||
export interface CanonicalActionResult {
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
/** Latest profile tags after migration */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration */
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for stage transition hooks.
|
||||
*/
|
||||
export interface UseBlobbiStageTransitionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a stage transition.
|
||||
*/
|
||||
export interface StageTransitionResult {
|
||||
/** Previous stage before transition */
|
||||
previousStage: BlobbiStage;
|
||||
/** New stage after transition */
|
||||
newStage: BlobbiStage;
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
/** Stats after decay was applied (before any transition bonuses) */
|
||||
decayedStats: {
|
||||
hunger: number;
|
||||
happiness: number;
|
||||
health: number;
|
||||
hygiene: number;
|
||||
energy: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hatch Hook ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to hatch an egg into a baby Blobbi.
|
||||
*
|
||||
* Transition: egg -> baby
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in egg stage
|
||||
* - Applies accumulated decay before transition
|
||||
* - Resets stats to healthy baby defaults (inherits health from egg)
|
||||
* - Sets last_decay_at to current timestamp
|
||||
*/
|
||||
export function useBlobbiHatch({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StageTransitionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to hatch');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'egg') {
|
||||
throw new Error('Only eggs can be hatched');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for hatching');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any stage transition.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Calculate Baby Stats ───
|
||||
// All stats reset to 100 when hatching — the baby starts fresh
|
||||
const babyStats = {
|
||||
hunger: STAT_MAX,
|
||||
happiness: STAT_MAX,
|
||||
health: STAT_MAX,
|
||||
hygiene: STAT_MAX,
|
||||
energy: STAT_MAX,
|
||||
};
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
|
||||
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Build the updated tags using the central merge function
|
||||
// Get streak updates (hatching counts as care activity!)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
const mergedTags = updateBlobbiTags(canonical.allTags, {
|
||||
stage: 'baby',
|
||||
state: 'active', // Newly hatched babies are awake
|
||||
hunger: babyStats.hunger.toString(),
|
||||
happiness: babyStats.happiness.toString(),
|
||||
health: babyStats.health.toString(),
|
||||
hygiene: babyStats.hygiene.toString(),
|
||||
energy: babyStats.energy.toString(),
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Validate and Repair Tags ───
|
||||
// Use the tag integrity guard to ensure all persistent tags are preserved
|
||||
// and task-related tags are properly cleaned up for stage transitions
|
||||
const repairResult = validateAndRepairBlobbiTags(
|
||||
mergedTags,
|
||||
canonical.allTags,
|
||||
{ cleanupTaskTags: true }
|
||||
);
|
||||
|
||||
if (repairResult.errors.length > 0) {
|
||||
console.error('[Hatch] Tag validation errors:', repairResult.errors);
|
||||
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
if (repairResult.repaired && import.meta.env.DEV) {
|
||||
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
|
||||
}
|
||||
|
||||
const newTags = repairResult.tags;
|
||||
|
||||
// ─── Generate New Content for Baby Stage ───
|
||||
// CRITICAL: Content must reflect the new stage
|
||||
const newContent = generateBlobbiContent(canonical.companion.name, 'baby');
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: newContent,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'egg',
|
||||
newStage: 'baby',
|
||||
name: canonical.companion.name,
|
||||
decayedStats: decayResult.stats,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Your egg hatched!',
|
||||
description: `${name} is now a baby Blobbi! Take good care of them.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to hatch',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Evolve Hook ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to evolve a baby Blobbi into an adult.
|
||||
*
|
||||
* Transition: baby -> adult
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in baby stage
|
||||
* - Applies accumulated decay before transition
|
||||
* - Preserves all stats (decay already applied)
|
||||
* - Sets last_decay_at to current timestamp
|
||||
*/
|
||||
export function useBlobbiEvolve({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StageTransitionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to evolve');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'baby') {
|
||||
if (companion.stage === 'egg') {
|
||||
throw new Error('Eggs must hatch before they can evolve');
|
||||
}
|
||||
if (companion.stage === 'adult') {
|
||||
throw new Error('This Blobbi is already fully evolved');
|
||||
}
|
||||
throw new Error('Only baby Blobbis can evolve');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for evolution');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any stage transition.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Adult Stats ───
|
||||
// Adult inherits all decayed stats from baby
|
||||
// No stat reset - evolution preserves current condition
|
||||
const adultStats = decayResult.stats;
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
|
||||
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Get streak updates (evolving counts as care activity!)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// Build the updated tags using the central merge function
|
||||
const mergedTags = updateBlobbiTags(canonical.allTags, {
|
||||
stage: 'adult',
|
||||
state: 'active', // Evolution completes with active state
|
||||
hunger: adultStats.hunger.toString(),
|
||||
happiness: adultStats.happiness.toString(),
|
||||
health: adultStats.health.toString(),
|
||||
hygiene: adultStats.hygiene.toString(),
|
||||
energy: adultStats.energy.toString(),
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Validate and Repair Tags ───
|
||||
// Use the tag integrity guard to ensure all persistent tags are preserved
|
||||
// and task-related tags are properly cleaned up for stage transitions
|
||||
const repairResult = validateAndRepairBlobbiTags(
|
||||
mergedTags,
|
||||
canonical.allTags,
|
||||
{ cleanupTaskTags: true }
|
||||
);
|
||||
|
||||
if (repairResult.errors.length > 0) {
|
||||
console.error('[Evolve] Tag validation errors:', repairResult.errors);
|
||||
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
if (repairResult.repaired && import.meta.env.DEV) {
|
||||
console.log('[Evolve] Tag repairs applied:', repairResult.repairs);
|
||||
}
|
||||
|
||||
const newTags = repairResult.tags;
|
||||
|
||||
// ─── Generate New Content for Adult Stage ───
|
||||
// CRITICAL: Content must reflect the new stage
|
||||
const newContent = generateBlobbiContent(canonical.companion.name, 'adult');
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: newContent,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'baby',
|
||||
newStage: 'adult',
|
||||
name: canonical.companion.name,
|
||||
decayedStats: decayResult.stats,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Evolution complete!',
|
||||
description: `${name} has evolved into an adult Blobbi!`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to evolve',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
clampStat,
|
||||
applyStat,
|
||||
hasMedicineEffectForEgg,
|
||||
hasHygieneEffectForEgg,
|
||||
incrementInteractionTaskTags,
|
||||
type InventoryAction,
|
||||
ACTION_METADATA,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
/**
|
||||
* Request payload for using an inventory item
|
||||
*/
|
||||
export interface UseItemRequest {
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
/** Number of items to use (defaults to 1) */
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of using an inventory item
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
itemName: string;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
|
||||
statsChanged: Record<string, number>;
|
||||
xpGained: number;
|
||||
newXP: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the useBlobbiUseInventoryItem hook
|
||||
*/
|
||||
export interface UseBlobbiUseInventoryItemParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called after ensuring companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
/** Latest profile tags after migration (use instead of profile.allTags) */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration (use instead of profile.storage) */
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Update profile event in local cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
// Import NostrEvent type
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Hook to use an inventory item on a Blobbi companion.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Validates the companion stage (eggs can't use items)
|
||||
* 2. Validates the item exists in storage
|
||||
* 3. Ensures canonical format before action
|
||||
* 4. Applies item effects to Blobbi stats
|
||||
* 5. Updates Blobbi state (kind 31124)
|
||||
* 6. Decrements item from profile storage (kind 11125)
|
||||
* 7. Invalidates relevant queries
|
||||
*/
|
||||
export function useBlobbiUseInventoryItem({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiUseInventoryItemParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to use items');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Validate quantity
|
||||
if (quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
if (!canUseAction(companion, action)) {
|
||||
const message = getStageRestrictionMessage(companion, action);
|
||||
throw new Error(message ?? 'This companion cannot use this item');
|
||||
}
|
||||
|
||||
// Validate item exists in shop catalog
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) {
|
||||
throw new Error('Item not found in catalog');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
}
|
||||
|
||||
// For eggs, validate that items have applicable effects
|
||||
const isEgg = companion.stage === 'egg';
|
||||
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
throw new Error('This medicine has no effect on eggs');
|
||||
}
|
||||
if (isEgg && action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect)) {
|
||||
throw new Error('This item has no cleaning effect on eggs');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for action');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any user interaction updates stats.
|
||||
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// Start with decayed stats as the base
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Validate Play Energy Requirements ───
|
||||
// For play actions, validate the Blobbi has enough energy AFTER decay
|
||||
if (action === 'play') {
|
||||
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
|
||||
const currentEnergy = statsAfterDecay.energy;
|
||||
|
||||
if (energyCost > 0 && currentEnergy < energyCost) {
|
||||
throw new Error(
|
||||
`Your Blobbi needs at least ${energyCost} energy to play with this toy (current: ${currentEnergy})`
|
||||
);
|
||||
}
|
||||
|
||||
// Also check if playing would have any effect at all
|
||||
// If happiness is maxed AND we can't spend energy, playing is pointless
|
||||
const happinessGain = shopItem.effect.happiness ?? 0;
|
||||
const currentHappiness = statsAfterDecay.happiness;
|
||||
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
|
||||
const wouldSpendEnergy = energyCost > 0 && currentEnergy >= energyCost;
|
||||
|
||||
if (!wouldGainHappiness && !wouldSpendEnergy) {
|
||||
throw new Error(
|
||||
'Playing would have no effect - your Blobbi is already at maximum happiness and has no energy to spend'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Apply Item Effects ───
|
||||
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
|
||||
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
|
||||
// won't give more than 100 health total.
|
||||
//
|
||||
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
|
||||
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
|
||||
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
|
||||
// - clean: count when hygiene or happiness INCREASES
|
||||
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
|
||||
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
|
||||
//
|
||||
// Use canonical companion stage for egg checks
|
||||
const isEggCompanion = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
let effectiveItemCount = 0; // Number of items that produced intended effects
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
// Egg medicine handling:
|
||||
// Eggs use the 3-stat model: health, hygiene, happiness
|
||||
// Medicine with health effect directly affects the egg's health stat
|
||||
// hunger and energy remain fixed at 100 for eggs
|
||||
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
// Apply health effect N times in sequence with clamping at each step
|
||||
// Only count items that actually INCREASED health (positive effect only)
|
||||
let currentHealth = statsAfterDecay.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHealth = currentHealth;
|
||||
currentHealth = applyStat(currentHealth, healthDelta);
|
||||
// Only count as effective if health increased (not just changed)
|
||||
if (healthDelta > 0 && currentHealth > prevHealth) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
// Track total actual change (may be less than healthDelta * quantity due to clamping)
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
|
||||
// Apply decayed values for other egg stats
|
||||
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
|
||||
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
|
||||
// hunger and energy stay at 100 for eggs
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else if (isEggCompanion && action === 'clean') {
|
||||
// Egg clean/hygiene handling:
|
||||
// Hygiene items affect the egg's hygiene stat
|
||||
// Some hygiene items also give happiness (e.g., bubble bath)
|
||||
// hunger and energy remain fixed at 100 for eggs
|
||||
|
||||
const hygieneDelta = shopItem.effect.hygiene ?? 0;
|
||||
const happinessDelta = shopItem.effect.happiness ?? 0;
|
||||
|
||||
// Apply effects N times in sequence
|
||||
// Only count items that INCREASED hygiene or happiness (positive effects only)
|
||||
let currentHygiene = statsAfterDecay.hygiene ?? 0;
|
||||
let currentHappiness = statsAfterDecay.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHygiene = currentHygiene;
|
||||
const prevHappiness = currentHappiness;
|
||||
currentHygiene = applyStat(currentHygiene, hygieneDelta);
|
||||
currentHappiness = applyStat(currentHappiness, happinessDelta);
|
||||
// Count as effective if hygiene OR happiness increased (positive effects only)
|
||||
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
|
||||
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
|
||||
if (hygieneIncreased || happinessIncreased) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
|
||||
statsUpdate.happiness = currentHappiness.toString();
|
||||
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
|
||||
if (totalHappinessChange !== 0) {
|
||||
statsChanged.happiness = totalHappinessChange;
|
||||
}
|
||||
|
||||
// Apply decayed health
|
||||
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
|
||||
// hunger and energy stay at 100 for eggs
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
// Normal stats application for baby/adult
|
||||
// Apply item effects N times in sequence ON TOP of decayed stats
|
||||
// Use action-aware effectiveness checking for XP calculation
|
||||
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
|
||||
const effect = shopItem.effect;
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevStats = { ...currentStats };
|
||||
currentStats = applyItemEffects(currentStats, effect);
|
||||
|
||||
// Action-aware effectiveness check:
|
||||
// Only count INTENDED positive effects, not negative side effects
|
||||
let isEffective = false;
|
||||
|
||||
if (action === 'feed') {
|
||||
// Feed: count when hunger/energy/health/happiness INCREASE
|
||||
// Do NOT count hygiene decrease (that's a side effect)
|
||||
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
|
||||
} else if (action === 'clean') {
|
||||
// Clean: count when hygiene or happiness INCREASES
|
||||
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hygieneIncreased || happinessIncreased;
|
||||
} else if (action === 'medicine') {
|
||||
// Medicine: count when health/energy/happiness INCREASE
|
||||
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = healthIncreased || energyIncreased || happinessIncreased;
|
||||
} else if (action === 'play') {
|
||||
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
|
||||
// Playing naturally consumes energy, so energy decrease counts as valid
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
|
||||
isEffective = happinessIncreased || energyDecreased;
|
||||
}
|
||||
|
||||
if (isEffective) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
|
||||
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
|
||||
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
|
||||
|
||||
statsUpdate.energy = clampStat(currentStats.energy).toString();
|
||||
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
|
||||
|
||||
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
|
||||
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
|
||||
|
||||
statsUpdate.health = clampStat(currentStats.health).toString();
|
||||
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
|
||||
}
|
||||
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// If incubating or evolving, increment the interaction counter for tasks
|
||||
const companionState = canonical.companion.state;
|
||||
let updatedTags = canonical.allTags;
|
||||
if (companionState === 'incubating') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
|
||||
} else if (companionState === 'evolving') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
|
||||
}
|
||||
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain (Based on effective item count) ───
|
||||
// Only grant XP for items that actually changed stats.
|
||||
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
|
||||
// This prevents XP farming by mass-using items after stats are already maxed.
|
||||
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
|
||||
const currentXP = canonical.companion.experience ?? 0;
|
||||
const newXP = applyXPGain(currentXP, xpGained);
|
||||
|
||||
const blobbiTags = updateBlobbiTags(updatedTags, {
|
||||
...statsUpdate,
|
||||
...streakUpdates,
|
||||
experience: newXP.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const blobbiEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: blobbiTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
|
||||
// instead of profile.storage/profile.allTags to avoid restoring
|
||||
// stale/legacy values after migration
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
invalidateProfile();
|
||||
|
||||
return {
|
||||
itemName: shopItem.name,
|
||||
action,
|
||||
quantity,
|
||||
effectiveItemCount, // How many items actually changed stats
|
||||
statsChanged,
|
||||
xpGained,
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ itemName, action, quantity, xpGained }) => {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
|
||||
const dailyActions: DailyMissionAction[] = ['interact'];
|
||||
if (action === 'feed') dailyActions.push('feed');
|
||||
if (action === 'clean') dailyActions.push('clean');
|
||||
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to use item',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* useClaimMissionReward - Hook for claiming daily mission rewards
|
||||
*
|
||||
* Handles:
|
||||
* - Persisting coin rewards to kind 11125 Blobbonaut profile
|
||||
* - Updating localStorage mission state
|
||||
* - Idempotent claiming (prevents double-credit)
|
||||
* - Optimistic cache updates
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbonautTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ClaimMissionRequest {
|
||||
missionId: string;
|
||||
}
|
||||
|
||||
/** Special ID for claiming the bonus mission */
|
||||
export const BONUS_MISSION_ID = 'bonus_daily_complete';
|
||||
|
||||
export interface ClaimMissionResult {
|
||||
missionId: string;
|
||||
coinsEarned: number;
|
||||
newTotalCoins: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useClaimMissionReward] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to claim daily mission rewards.
|
||||
*
|
||||
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
|
||||
* ensuring rewards are stored on-chain rather than just in localStorage.
|
||||
*
|
||||
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
|
||||
* @param updateProfileEvent - Callback to update the profile in the query cache
|
||||
*/
|
||||
export function useClaimMissionReward(
|
||||
currentProfile: BlobbonautProfile | null,
|
||||
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
|
||||
) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ missionId }: ClaimMissionRequest): Promise<ClaimMissionResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to claim rewards');
|
||||
}
|
||||
|
||||
if (!currentProfile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
|
||||
}
|
||||
|
||||
// Handle bonus mission claim
|
||||
if (missionId === BONUS_MISSION_ID) {
|
||||
// Check if bonus is available
|
||||
if (!isBonusMissionAvailable(missionsState!)) {
|
||||
throw new Error('Bonus mission not available yet');
|
||||
}
|
||||
|
||||
// Check if already claimed
|
||||
if (isBonusMissionClaimed(missionsState!)) {
|
||||
throw new Error('Bonus reward already claimed');
|
||||
}
|
||||
|
||||
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
});
|
||||
|
||||
// Publish updated profile event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update the query cache
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Update localStorage to mark bonus as claimed
|
||||
const updatedState: DailyMissionsState = {
|
||||
...missionsState!,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
};
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true, isBonus: true }
|
||||
}));
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle regular mission claim
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (!mission) {
|
||||
throw new Error('Mission not found');
|
||||
}
|
||||
|
||||
// Check if already claimed (idempotency check)
|
||||
if (mission.claimed) {
|
||||
throw new Error('Reward already claimed');
|
||||
}
|
||||
|
||||
// Check if mission is completed
|
||||
if (!mission.completed) {
|
||||
throw new Error('Mission not completed yet');
|
||||
}
|
||||
|
||||
const coinsToAdd = mission.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
});
|
||||
|
||||
// Publish updated profile event to kind 11125
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update the query cache optimistically
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Now update localStorage to mark mission as claimed
|
||||
const updatedMissions = missionsState!.missions.map(m =>
|
||||
m.id === missionId ? { ...m, claimed: true } : m
|
||||
);
|
||||
|
||||
const updatedState: DailyMissionsState = {
|
||||
...missionsState!,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
};
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true }
|
||||
}));
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ coinsEarned }) => {
|
||||
// Invalidate profile query to ensure fresh data
|
||||
if (user?.pubkey) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: 'Reward Claimed!',
|
||||
description: `You earned ${coinsEarned} coins.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
// Don't show error for already claimed (user might have double-clicked)
|
||||
if (error.message === 'Reward already claimed' || error.message === 'Bonus reward already claimed') {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to Claim Reward',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* useDailyMissions - Hook for managing Blobbi daily missions
|
||||
*
|
||||
* Provides:
|
||||
* - Daily mission state management with localStorage persistence
|
||||
* - Automatic daily reset
|
||||
* - Progress tracking functions
|
||||
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
|
||||
* - Stage-based filtering (only shows missions user can complete)
|
||||
* - Bonus mission tracking
|
||||
*
|
||||
* Note: Reward claiming should be done via useClaimMissionReward hook,
|
||||
* which persists coins to the kind 11125 Blobbonaut profile.
|
||||
*/
|
||||
|
||||
import { useMemo, useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
areAllMissionsCompleted,
|
||||
areAllMissionsClaimed,
|
||||
getTotalPotentialReward,
|
||||
getTodayClaimedReward,
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
getRerollsRemaining,
|
||||
MAX_DAILY_REROLLS,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseDailyMissionsOptions {
|
||||
/** Available Blobbi stages the user has (filters eligible missions) */
|
||||
availableStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
export interface UseDailyMissionsResult {
|
||||
/** Current daily missions state */
|
||||
missions: DailyMission[];
|
||||
/** Whether all missions are completed */
|
||||
allCompleted: boolean;
|
||||
/** Whether all missions are claimed */
|
||||
allClaimed: boolean;
|
||||
/** Total potential reward for today (including bonus if available) */
|
||||
totalPotentialReward: number;
|
||||
/** Total claimed reward for today */
|
||||
todayClaimedReward: number;
|
||||
/** Lifetime total coins earned from daily missions */
|
||||
lifetimeCoinsEarned: number;
|
||||
/** Whether the bonus mission is available (all regular missions completed) */
|
||||
bonusAvailable: boolean;
|
||||
/** Whether the bonus mission has been claimed */
|
||||
bonusClaimed: boolean;
|
||||
/** Bonus mission reward amount */
|
||||
bonusReward: number;
|
||||
/** Whether user has no eligible missions (e.g., only eggs) */
|
||||
noMissionsAvailable: boolean;
|
||||
/** Number of rerolls remaining for today */
|
||||
rerollsRemaining: number;
|
||||
/** Maximum rerolls allowed per day */
|
||||
maxRerolls: number;
|
||||
/** Force refresh missions (for testing or manual reset) */
|
||||
forceReset: () => void;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useDailyMissions] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
|
||||
const { availableStages } = options;
|
||||
const { user } = useCurrentUser();
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
// Read state directly from localStorage, with a version counter to trigger re-reads
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Read from localStorage on every render when version changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
|
||||
const state = useMemo(() => readMissionsState(), [version]);
|
||||
|
||||
// Wrapper to write state and update version
|
||||
const setState = useCallback((newState: DailyMissionsState) => {
|
||||
writeMissionsState(newState);
|
||||
setVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
// Listen for external updates from mutations (reroll, claim, progress tracking)
|
||||
// This re-reads localStorage when other hooks modify it directly
|
||||
useEffect(() => {
|
||||
const handleExternalUpdate = () => {
|
||||
// Bump version to trigger a re-read from localStorage
|
||||
setVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
window.addEventListener('daily-missions-updated', handleExternalUpdate);
|
||||
return () => window.removeEventListener('daily-missions-updated', handleExternalUpdate);
|
||||
}, []);
|
||||
|
||||
// Stable key for availableStages to use in dependencies
|
||||
const stagesKey = availableStages?.sort().join(',') ?? '';
|
||||
|
||||
// Ensure we have valid state for today
|
||||
const currentState = useMemo(() => {
|
||||
// Check if we need to reset for a new day
|
||||
if (needsDailyReset(state)) {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
// Persist the reset state (this will trigger version bump via setState)
|
||||
writeMissionsState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state && state.rerollsRemaining === undefined) {
|
||||
const migratedState = {
|
||||
...state,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
};
|
||||
writeMissionsState(migratedState);
|
||||
return migratedState;
|
||||
}
|
||||
|
||||
return state!;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, pubkey, stagesKey]);
|
||||
|
||||
// Force reset missions (for testing)
|
||||
const forceReset = () => {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
setState(newState);
|
||||
};
|
||||
|
||||
// Computed values
|
||||
const missions = currentState.missions;
|
||||
const allCompleted = areAllMissionsCompleted(currentState);
|
||||
const allClaimed = areAllMissionsClaimed(currentState);
|
||||
const bonusAvailable = isBonusMissionAvailable(currentState);
|
||||
const bonusClaimed = isBonusMissionClaimed(currentState);
|
||||
const bonusReward = BONUS_MISSION_DEFINITION.reward;
|
||||
const noMissionsAvailable = missions.length === 0;
|
||||
const rerollsRemaining = getRerollsRemaining(currentState);
|
||||
const maxRerolls = MAX_DAILY_REROLLS;
|
||||
|
||||
// Total potential includes bonus if regular missions exist
|
||||
const basePotentialReward = getTotalPotentialReward(currentState);
|
||||
const totalPotentialReward = missions.length > 0
|
||||
? basePotentialReward + bonusReward
|
||||
: 0;
|
||||
|
||||
// Today's claimed includes bonus if claimed
|
||||
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
|
||||
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
|
||||
|
||||
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
|
||||
|
||||
return {
|
||||
missions,
|
||||
allCompleted,
|
||||
allClaimed,
|
||||
totalPotentialReward,
|
||||
todayClaimedReward,
|
||||
lifetimeCoinsEarned,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
maxRerolls,
|
||||
forceReset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
// src/blobbi/actions/hooks/useEvolveTasks.ts
|
||||
|
||||
/**
|
||||
* Hook to compute evolve task progress from Nostr events and current stats.
|
||||
*
|
||||
* CRITICAL ARCHITECTURE:
|
||||
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
|
||||
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
|
||||
*
|
||||
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import {
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
KIND_PROFILE_METADATA,
|
||||
KIND_SHORT_TEXT_NOTE,
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
sanitizeToHashtag,
|
||||
type HatchTask,
|
||||
type TaskType,
|
||||
} from './useHatchTasks';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for wall edit events */
|
||||
export const KIND_WALL_EDIT = 16769;
|
||||
|
||||
/** Required themes for evolve task */
|
||||
export const EVOLVE_REQUIRED_THEMES = 3;
|
||||
|
||||
/** Required color moments for evolve task */
|
||||
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
|
||||
|
||||
/** Required posts for evolve task (lighter than hatch - just 1 evolve-specific post) */
|
||||
export const EVOLVE_REQUIRED_POSTS = 1;
|
||||
|
||||
/** Required interactions for evolve task */
|
||||
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
|
||||
|
||||
/** Prefix text for Blobbi evolve post */
|
||||
export const BLOBBI_EVOLVE_POST_PREFIX = 'Hello Nostr! Posting to evolve';
|
||||
|
||||
/** Stat threshold for evolve dynamic task (all stats >= 80) */
|
||||
export const EVOLVE_STAT_THRESHOLD = 80;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Re-export task types for convenience
|
||||
export type { HatchTask as EvolveTask, TaskType };
|
||||
|
||||
/**
|
||||
* Result of computing evolve tasks.
|
||||
*/
|
||||
export interface EvolveTasksResult {
|
||||
tasks: HatchTask[];
|
||||
/** All persistent tasks are complete */
|
||||
persistentTasksComplete: boolean;
|
||||
/** Dynamic stat task is complete */
|
||||
dynamicTaskComplete: boolean;
|
||||
/** All tasks (persistent + dynamic) are complete - required to evolve */
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Refetch task progress */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi evolve post.
|
||||
* Must contain the evolve prefix and all required hashtags including the Blobbi name.
|
||||
*
|
||||
* @param event - The Nostr event to validate
|
||||
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
|
||||
*/
|
||||
export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolean {
|
||||
// Check content starts with evolve prefix
|
||||
if (!event.content.startsWith(BLOBBI_EVOLVE_POST_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for required hashtags in tags
|
||||
const hashtags = event.tags
|
||||
.filter(tag => tag[0] === 't')
|
||||
.map(tag => tag[1]?.toLowerCase());
|
||||
|
||||
// All required hashtags must be present
|
||||
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
|
||||
hashtags.includes(required.toLowerCase())
|
||||
);
|
||||
|
||||
if (!hasRequiredHashtags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blobbi name hashtag must also be present
|
||||
const blobbiHashtag = sanitizeToHashtag(blobbiName);
|
||||
return hashtags.includes(blobbiHashtag);
|
||||
}
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to compute evolve task progress from Nostr events and current stats.
|
||||
*
|
||||
* PERSISTENT TASKS (event-based, can be cached):
|
||||
* 1. Create 3 Themes (kind 36767)
|
||||
* 2. Create 3 Color Moments (kind 3367)
|
||||
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
|
||||
* 4. Interact 21 times (tracked via companion.tasks cache)
|
||||
* 5. Edit Wall once (kind 16769)
|
||||
*
|
||||
* DYNAMIC TASK (stat-based, NEVER cached):
|
||||
* 6. Maintain All Stats >= 80
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be in evolving state)
|
||||
* @param interactionCount - Current interaction count from companion tasks cache
|
||||
*/
|
||||
export function useEvolveTasks(
|
||||
companion: BlobbiCompanion | null,
|
||||
interactionCount?: number
|
||||
): EvolveTasksResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const stateStartedAt = companion?.stateStartedAt;
|
||||
const isEvolving = companion?.state === 'evolving';
|
||||
|
||||
// Query for all relevant events
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['evolve-tasks', pubkey, stateStartedAt],
|
||||
queryFn: async () => {
|
||||
if (!pubkey || !stateStartedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build filters for events we need
|
||||
const filters: NostrFilter[] = [
|
||||
// Theme definitions after start
|
||||
{
|
||||
kinds: [KIND_THEME_DEFINITION],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Color moments after start
|
||||
{
|
||||
kinds: [KIND_COLOR_MOMENT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Posts after start (will filter for valid evolve posts)
|
||||
{
|
||||
kinds: [KIND_SHORT_TEXT_NOTE],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Only need 1 valid evolve post
|
||||
},
|
||||
// Wall edits after start
|
||||
{
|
||||
kinds: [KIND_WALL_EDIT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1, // Only need 1
|
||||
},
|
||||
// Profile metadata after start (for Blobbi shape check)
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// Execute all queries
|
||||
const events = await nostr.query(filters);
|
||||
|
||||
// Categorize events
|
||||
const themeEvents = events.filter(e =>
|
||||
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const colorMomentEvents = events.filter(e =>
|
||||
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const postEvents = events.filter(e =>
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const wallEditEvents = events.filter(e =>
|
||||
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Get latest profile after start
|
||||
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
|
||||
const profileAfter = profileEvents
|
||||
.filter(e => e.created_at >= stateStartedAt)
|
||||
.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
|
||||
return {
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
wallEditEvents,
|
||||
profileAfter,
|
||||
};
|
||||
},
|
||||
enabled: !!pubkey && !!stateStartedAt && isEvolving,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
refetchInterval: 60_000, // Refetch every minute
|
||||
});
|
||||
|
||||
// ─── Compute PERSISTENT Tasks ───
|
||||
const tasks: HatchTask[] = [];
|
||||
|
||||
// 1. Create 3 Themes (PERSISTENT)
|
||||
const themeCount = data?.themeEvents?.length ?? 0;
|
||||
const themesCompleted = themeCount >= EVOLVE_REQUIRED_THEMES;
|
||||
tasks.push({
|
||||
id: 'create_themes',
|
||||
name: 'Create Themes',
|
||||
description: `Create ${EVOLVE_REQUIRED_THEMES} custom themes`,
|
||||
current: Math.min(themeCount, EVOLVE_REQUIRED_THEMES),
|
||||
required: EVOLVE_REQUIRED_THEMES,
|
||||
completed: themesCompleted,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/themes',
|
||||
actionLabel: 'Create Theme',
|
||||
});
|
||||
|
||||
// 2. Create 3 Color Moments (PERSISTENT)
|
||||
const colorMomentCount = data?.colorMomentEvents?.length ?? 0;
|
||||
const colorMomentsCompleted = colorMomentCount >= EVOLVE_REQUIRED_COLOR_MOMENTS;
|
||||
tasks.push({
|
||||
id: 'color_moments',
|
||||
name: 'Color Moments',
|
||||
description: `Share ${EVOLVE_REQUIRED_COLOR_MOMENTS} color moments on espy`,
|
||||
current: Math.min(colorMomentCount, EVOLVE_REQUIRED_COLOR_MOMENTS),
|
||||
required: EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
completed: colorMomentsCompleted,
|
||||
type: 'persistent',
|
||||
action: 'external_link',
|
||||
actionTarget: 'https://espy.you/',
|
||||
actionLabel: 'Open espy',
|
||||
});
|
||||
|
||||
// 3. Create 1 Evolve Post (PERSISTENT) - lighter than hatch
|
||||
const blobbiName = companion?.name ?? '';
|
||||
const validPosts = data?.postEvents?.filter(e => isValidEvolvePost(e, blobbiName)) ?? [];
|
||||
const postCount = validPosts.length;
|
||||
const postsCompleted = postCount >= EVOLVE_REQUIRED_POSTS;
|
||||
tasks.push({
|
||||
id: 'create_posts',
|
||||
name: 'Share Evolution',
|
||||
description: 'Post about your Blobbi evolving',
|
||||
current: Math.min(postCount, EVOLVE_REQUIRED_POSTS),
|
||||
required: EVOLVE_REQUIRED_POSTS,
|
||||
completed: postsCompleted,
|
||||
type: 'persistent',
|
||||
action: 'open_modal',
|
||||
actionTarget: 'blobbi_post',
|
||||
actionLabel: 'Create Post',
|
||||
});
|
||||
|
||||
// 4. Interact 21 times (PERSISTENT)
|
||||
const interactions = interactionCount ?? 0;
|
||||
const interactionsCompleted = interactions >= EVOLVE_REQUIRED_INTERACTIONS;
|
||||
tasks.push({
|
||||
id: 'interactions',
|
||||
name: 'Interact with Blobbi',
|
||||
description: `Care for your Blobbi ${EVOLVE_REQUIRED_INTERACTIONS} times`,
|
||||
current: Math.min(interactions, EVOLVE_REQUIRED_INTERACTIONS),
|
||||
required: EVOLVE_REQUIRED_INTERACTIONS,
|
||||
completed: interactionsCompleted,
|
||||
type: 'persistent',
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// 5. Edit Wall once (PERSISTENT)
|
||||
const wallEditCount = data?.wallEditEvents?.length ?? 0;
|
||||
const hasWallEdit = wallEditCount >= 1;
|
||||
tasks.push({
|
||||
id: 'edit_wall',
|
||||
name: 'Edit Your Wall',
|
||||
description: 'Customize your profile wall',
|
||||
current: hasWallEdit ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasWallEdit,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/settings/profile',
|
||||
actionLabel: 'Edit Wall',
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
// 7. Maintain All Stats >= 80
|
||||
const stats = companion?.stats ?? {};
|
||||
const hunger = stats.hunger ?? 0;
|
||||
const happiness = stats.happiness ?? 0;
|
||||
const health = stats.health ?? 0;
|
||||
const hygiene = stats.hygiene ?? 0;
|
||||
const energy = stats.energy ?? 0;
|
||||
|
||||
const statsOk =
|
||||
hunger >= EVOLVE_STAT_THRESHOLD &&
|
||||
happiness >= EVOLVE_STAT_THRESHOLD &&
|
||||
health >= EVOLVE_STAT_THRESHOLD &&
|
||||
hygiene >= EVOLVE_STAT_THRESHOLD &&
|
||||
energy >= EVOLVE_STAT_THRESHOLD;
|
||||
|
||||
// Calculate minimum stat for progress display
|
||||
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
|
||||
|
||||
tasks.push({
|
||||
id: 'maintain_stats',
|
||||
name: 'Peak Condition',
|
||||
description: `Keep all stats above ${EVOLVE_STAT_THRESHOLD}`,
|
||||
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
|
||||
required: EVOLVE_STAT_THRESHOLD,
|
||||
completed: statsOk,
|
||||
type: 'dynamic', // CRITICAL: Never persist this task
|
||||
// No action - just care for your Blobbi
|
||||
});
|
||||
|
||||
// ─── Compute Completion States ───
|
||||
const persistentTasks = tasks.filter(t => t.type === 'persistent');
|
||||
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
|
||||
|
||||
const persistentTasksComplete = persistentTasks.every(t => t.completed);
|
||||
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
|
||||
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
|
||||
|
||||
return {
|
||||
tasks,
|
||||
persistentTasksComplete,
|
||||
dynamicTaskComplete,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current interaction count for evolve from companion task cache.
|
||||
*/
|
||||
export function getEvolveInteractionCount(companion: BlobbiCompanion | null): number {
|
||||
if (!companion) return 0;
|
||||
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
|
||||
return interactionTask?.value ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// src/blobbi/actions/hooks/useHatchTasks.ts
|
||||
|
||||
/**
|
||||
* Hook to compute hatch task progress from Nostr events.
|
||||
*
|
||||
* CRITICAL ARCHITECTURE:
|
||||
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
|
||||
*
|
||||
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
|
||||
* All persistent tasks are computed dynamically from events with created_at >= state_started_at.
|
||||
*
|
||||
* Note: Egg stats no longer decay, so there are no dynamic tasks for hatching.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for theme definition events */
|
||||
export const KIND_THEME_DEFINITION = 36767;
|
||||
/** Kind for color moment events (espy.you) */
|
||||
export const KIND_COLOR_MOMENT = 3367;
|
||||
/** Kind for profile metadata */
|
||||
export const KIND_PROFILE_METADATA = 0;
|
||||
/** Kind for short text notes */
|
||||
export const KIND_SHORT_TEXT_NOTE = 1;
|
||||
|
||||
/** Required interactions to complete the hatch interactions task */
|
||||
export const HATCH_REQUIRED_INTERACTIONS = 7;
|
||||
|
||||
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
|
||||
|
||||
/** Prefix text for Blobbi hatch post */
|
||||
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
|
||||
|
||||
/**
|
||||
* Sanitize a name into a valid hashtag format.
|
||||
* Must match the implementation in BlobbiPostModal.tsx.
|
||||
*/
|
||||
export function sanitizeToHashtag(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
// Remove emojis and special characters, keep letters, numbers, underscores
|
||||
.replace(/[^\p{L}\p{N}_]/gu, '')
|
||||
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
|
||||
.replace(/^(\d)/, 'blobbi$1')
|
||||
// Limit length
|
||||
.slice(0, 30)
|
||||
// Fallback if empty
|
||||
|| 'myblobbi';
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Task type classification.
|
||||
* - persistent: Based on Nostr events, can be cached in tags
|
||||
* - dynamic: Based on current stats, NEVER stored in tags
|
||||
*/
|
||||
export type TaskType = 'persistent' | 'dynamic';
|
||||
|
||||
/**
|
||||
* Individual task definition.
|
||||
*/
|
||||
export interface HatchTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Current progress value */
|
||||
current: number;
|
||||
/** Required value for completion */
|
||||
required: number;
|
||||
/** Whether the task is complete */
|
||||
completed: boolean;
|
||||
/** Task type - persistent (event-based) or dynamic (stat-based) */
|
||||
type: TaskType;
|
||||
/** Action to perform (if applicable) */
|
||||
action?: 'navigate' | 'open_modal' | 'external_link';
|
||||
/** Target for the action */
|
||||
actionTarget?: string;
|
||||
/** Button label */
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of computing hatch tasks.
|
||||
*/
|
||||
export interface HatchTasksResult {
|
||||
tasks: HatchTask[];
|
||||
/** All persistent tasks are complete */
|
||||
persistentTasksComplete: boolean;
|
||||
/** Dynamic stat task is complete */
|
||||
dynamicTaskComplete: boolean;
|
||||
/** All tasks (persistent + dynamic) are complete - required to hatch */
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Refetch task progress */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi hatch post.
|
||||
* Must contain the required prefix and all required hashtags including the Blobbi name.
|
||||
*
|
||||
* @param event - The Nostr event to validate
|
||||
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
|
||||
*/
|
||||
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
|
||||
// Check content starts with prefix
|
||||
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for required hashtags in tags
|
||||
const hashtags = event.tags
|
||||
.filter(tag => tag[0] === 't')
|
||||
.map(tag => tag[1]?.toLowerCase());
|
||||
|
||||
// All required hashtags must be present
|
||||
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
|
||||
hashtags.includes(required.toLowerCase())
|
||||
);
|
||||
|
||||
if (!hasRequiredHashtags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blobbi name hashtag must also be present
|
||||
const blobbiHashtag = sanitizeToHashtag(blobbiName);
|
||||
return hashtags.includes(blobbiHashtag);
|
||||
}
|
||||
|
||||
// Legacy function name for backwards compatibility
|
||||
export const isValidBlobbiPost = isValidHatchPost;
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to compute hatch task progress from Nostr events and current stats.
|
||||
*
|
||||
* PERSISTENT TASKS (event-based, can be cached):
|
||||
* 1. Create Theme (kind 36767) - ≥1 event after start
|
||||
* 2. Color Moment (kind 3367) - ≥1 event after start
|
||||
* 3. Create Post (kind 1) - ≥1 valid Blobbi hatch post after start
|
||||
* 4. Interactions - 7 total (tracked via companion.tasks cache)
|
||||
*
|
||||
* Note: Egg stats no longer decay, so the "maintain stats" dynamic task
|
||||
* has been removed. The baby/adult evolve equivalent is still in useEvolveTasks.
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be incubating)
|
||||
* @param interactionCount - Current interaction count from companion tasks cache
|
||||
*/
|
||||
export function useHatchTasks(
|
||||
companion: BlobbiCompanion | null,
|
||||
interactionCount?: number
|
||||
): HatchTasksResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const stateStartedAt = companion?.stateStartedAt;
|
||||
const isIncubating = companion?.state === 'incubating';
|
||||
|
||||
// Query for all relevant events
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['hatch-tasks', pubkey, stateStartedAt],
|
||||
queryFn: async () => {
|
||||
if (!pubkey || !stateStartedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build filters for events we need
|
||||
const filters: NostrFilter[] = [
|
||||
// Theme definitions after start
|
||||
{
|
||||
kinds: [KIND_THEME_DEFINITION],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Color moments after start
|
||||
{
|
||||
kinds: [KIND_COLOR_MOMENT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Posts after start (will filter for valid Blobbi posts)
|
||||
{
|
||||
kinds: [KIND_SHORT_TEXT_NOTE],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Reasonable limit
|
||||
},
|
||||
// Profile metadata - need both before and after start
|
||||
// Get latest before start
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
until: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
// Get latest after start
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// Execute all queries
|
||||
const events = await nostr.query(filters);
|
||||
|
||||
// Categorize events
|
||||
const themeEvents = events.filter(e =>
|
||||
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const colorMomentEvents = events.filter(e =>
|
||||
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const postEvents = events.filter(e =>
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Separate profile events into before and after
|
||||
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
|
||||
const profileBefore = profileEvents
|
||||
.filter(e => e.created_at < stateStartedAt)
|
||||
.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
const profileAfter = profileEvents
|
||||
.filter(e => e.created_at >= stateStartedAt)
|
||||
.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
|
||||
return {
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
profileBefore,
|
||||
profileAfter,
|
||||
};
|
||||
},
|
||||
enabled: !!pubkey && !!stateStartedAt && isIncubating,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
refetchInterval: 60_000, // Refetch every minute
|
||||
});
|
||||
|
||||
// ─── Compute PERSISTENT Tasks ───
|
||||
const tasks: HatchTask[] = [];
|
||||
|
||||
// 1. Create Theme (PERSISTENT)
|
||||
const hasTheme = (data?.themeEvents?.length ?? 0) >= 1;
|
||||
tasks.push({
|
||||
id: 'create_theme',
|
||||
name: 'Create Theme',
|
||||
description: 'Create a custom theme for your profile',
|
||||
current: hasTheme ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasTheme,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/themes',
|
||||
actionLabel: 'Create Theme',
|
||||
});
|
||||
|
||||
// 2. Color Moment (PERSISTENT)
|
||||
const hasColorMoment = (data?.colorMomentEvents?.length ?? 0) >= 1;
|
||||
tasks.push({
|
||||
id: 'color_moment',
|
||||
name: 'Color Moment',
|
||||
description: 'Share a color moment on espy',
|
||||
current: hasColorMoment ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasColorMoment,
|
||||
type: 'persistent',
|
||||
action: 'external_link',
|
||||
actionTarget: 'https://espy.you/',
|
||||
actionLabel: 'Open espy',
|
||||
});
|
||||
|
||||
// 3. Create Post (PERSISTENT)
|
||||
const blobbiName = companion?.name ?? '';
|
||||
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e, blobbiName)) ?? [];
|
||||
const hasValidPost = validPosts.length >= 1;
|
||||
tasks.push({
|
||||
id: 'create_post',
|
||||
name: 'Create Post',
|
||||
description: 'Share a post about hatching your Blobbi',
|
||||
current: hasValidPost ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasValidPost,
|
||||
type: 'persistent',
|
||||
action: 'open_modal',
|
||||
actionTarget: 'blobbi_post',
|
||||
actionLabel: 'Create Post',
|
||||
});
|
||||
|
||||
// 5. Interactions (PERSISTENT)
|
||||
const interactions = interactionCount ?? 0;
|
||||
const interactionsCompleted = interactions >= HATCH_REQUIRED_INTERACTIONS;
|
||||
tasks.push({
|
||||
id: 'interactions',
|
||||
name: 'Interact with Blobbi',
|
||||
description: `Care for your Blobbi ${HATCH_REQUIRED_INTERACTIONS} times`,
|
||||
current: Math.min(interactions, HATCH_REQUIRED_INTERACTIONS),
|
||||
required: HATCH_REQUIRED_INTERACTIONS,
|
||||
completed: interactionsCompleted,
|
||||
type: 'persistent',
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// ─── Compute Completion States ───
|
||||
const persistentTasks = tasks.filter(t => t.type === 'persistent');
|
||||
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
|
||||
|
||||
const persistentTasksComplete = persistentTasks.every(t => t.completed);
|
||||
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
|
||||
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
|
||||
|
||||
return {
|
||||
tasks,
|
||||
persistentTasksComplete,
|
||||
dynamicTaskComplete,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current interaction count from companion task cache.
|
||||
*/
|
||||
export function getInteractionCount(companion: BlobbiCompanion | null): number {
|
||||
if (!companion) return 0;
|
||||
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
|
||||
return interactionTask?.value ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tasks to only persistent tasks (for tag sync).
|
||||
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
|
||||
*/
|
||||
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
|
||||
return tasks.filter(t => t.type === 'persistent');
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* useRerollMission - Hook for rerolling daily missions
|
||||
*
|
||||
* Handles:
|
||||
* - Replacing a mission with a new one from the pool
|
||||
* - Tracking reroll usage (max 3 per day)
|
||||
* - Respecting stage-based mission filtering
|
||||
* - Persisting state to localStorage
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
rerollMission,
|
||||
canRerollMission,
|
||||
getRerollsRemaining,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RerollMissionRequest {
|
||||
missionId: string;
|
||||
availableStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
export interface RerollMissionResult {
|
||||
oldMissionId: string;
|
||||
newMission: DailyMission;
|
||||
rerollsRemaining: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
const state = JSON.parse(stored) as DailyMissionsState;
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state.rerollsRemaining === undefined) {
|
||||
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useRerollMission] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to reroll a daily mission.
|
||||
*
|
||||
* Replaces the specified mission with a new one from the pool,
|
||||
* respecting stage-based filtering and avoiding duplicates.
|
||||
*/
|
||||
export function useRerollMission() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to reroll missions');
|
||||
}
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
|
||||
}
|
||||
|
||||
// Check if reroll is allowed
|
||||
if (!canRerollMission(missionsState!, missionId)) {
|
||||
const rerollsLeft = getRerollsRemaining(missionsState!);
|
||||
if (rerollsLeft <= 0) {
|
||||
throw new Error('No rerolls remaining today');
|
||||
}
|
||||
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (mission?.completed || mission?.claimed) {
|
||||
throw new Error('Cannot reroll completed or claimed missions');
|
||||
}
|
||||
|
||||
throw new Error('Cannot reroll this mission');
|
||||
}
|
||||
|
||||
// Perform the reroll
|
||||
const result = rerollMission(missionsState!, missionId, availableStages);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
|
||||
}
|
||||
|
||||
// Persist the updated state
|
||||
writeMissionsState(result.state);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: {
|
||||
missionId,
|
||||
rerolled: true,
|
||||
newMissionId: result.newMission.id,
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
oldMissionId: missionId,
|
||||
newMission: result.newMission,
|
||||
rerollsRemaining: getRerollsRemaining(result.state),
|
||||
};
|
||||
},
|
||||
onSuccess: ({ newMission, rerollsRemaining }) => {
|
||||
const rerollText = rerollsRemaining === 1
|
||||
? '1 reroll left'
|
||||
: rerollsRemaining === 0
|
||||
? 'No rerolls left'
|
||||
: `${rerollsRemaining} rerolls left`;
|
||||
|
||||
toast({
|
||||
title: 'Mission Replaced',
|
||||
description: `New mission: ${newMission.title}. ${rerollText}.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to Reroll',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// src/blobbi/actions/index.ts
|
||||
|
||||
// Components
|
||||
export { BlobbiActionsModal } from './components/BlobbiActionsModal';
|
||||
export { BlobbiActionInventoryModal } from './components/BlobbiActionInventoryModal';
|
||||
export { PlayMusicModal } from './components/PlayMusicModal';
|
||||
export { SingModal } from './components/SingModal';
|
||||
export { InlineMusicPlayer } from './components/InlineMusicPlayer';
|
||||
export { InlineSingCard } from './components/InlineSingCard';
|
||||
export { HatchTasksPanel } from './components/HatchTasksPanel';
|
||||
export { TasksPanel } from './components/TasksPanel';
|
||||
export { BlobbiPostModal } from './components/BlobbiPostModal';
|
||||
export { StartIncubationDialog } from './components/StartIncubationDialog';
|
||||
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
|
||||
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
|
||||
|
||||
// Hooks
|
||||
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
|
||||
export type { UseItemRequest, UseItemResult, UseBlobbiUseInventoryItemParams } from './hooks/useBlobbiUseInventoryItem';
|
||||
|
||||
export { useBlobbiHatch, useBlobbiEvolve } from './hooks/useBlobbiStageTransition';
|
||||
export type {
|
||||
UseBlobbiStageTransitionParams,
|
||||
StageTransitionResult,
|
||||
CanonicalActionResult,
|
||||
} from './hooks/useBlobbiStageTransition';
|
||||
|
||||
export {
|
||||
useStartIncubation,
|
||||
useStopIncubation,
|
||||
useStartEvolution,
|
||||
useStopEvolution,
|
||||
useSyncTaskCompletions,
|
||||
} from './hooks/useBlobbiIncubation';
|
||||
export type {
|
||||
StartIncubationMode,
|
||||
StartIncubationRequest,
|
||||
UseStartIncubationParams,
|
||||
StartIncubationResult,
|
||||
UseStopIncubationParams,
|
||||
StopIncubationResult,
|
||||
UseStartEvolutionParams,
|
||||
StartEvolutionResult,
|
||||
UseStopEvolutionParams,
|
||||
StopEvolutionResult,
|
||||
UseSyncTaskCompletionsParams,
|
||||
TaskCompletionToSync,
|
||||
} from './hooks/useBlobbiIncubation';
|
||||
|
||||
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
|
||||
export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from './hooks/useActiveTaskProcess';
|
||||
|
||||
export {
|
||||
useHatchTasks,
|
||||
getInteractionCount,
|
||||
filterPersistentTasks,
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
REQUIRED_INTERACTIONS, // Legacy export
|
||||
BLOBBI_POST_PREFIX,
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
} from './hooks/useHatchTasks';
|
||||
export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTasks';
|
||||
|
||||
export {
|
||||
useEvolveTasks,
|
||||
getEvolveInteractionCount,
|
||||
isValidEvolvePost,
|
||||
KIND_WALL_EDIT,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_REQUIRED_POSTS,
|
||||
EVOLVE_REQUIRED_INTERACTIONS,
|
||||
EVOLVE_STAT_THRESHOLD,
|
||||
BLOBBI_EVOLVE_POST_PREFIX,
|
||||
} from './hooks/useEvolveTasks';
|
||||
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
|
||||
|
||||
export { useBlobbiDirectAction, DIRECT_ACTION_HAPPINESS_EFFECTS } from './hooks/useBlobbiDirectAction';
|
||||
export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionParams } from './hooks/useBlobbiDirectAction';
|
||||
|
||||
export { useAudioPlayback } from './hooks/useAudioPlayback';
|
||||
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
|
||||
|
||||
// Track catalog
|
||||
export {
|
||||
BLOBBI_TRACK_CATALOG,
|
||||
getAllTracks,
|
||||
getTrackById,
|
||||
formatTrackDuration,
|
||||
type BlobbiTrack,
|
||||
} from './lib/blobbi-track-catalog';
|
||||
|
||||
// Activity state
|
||||
export {
|
||||
createMusicActivity,
|
||||
createSingActivity,
|
||||
createNoActivity,
|
||||
type InlineActivityType,
|
||||
type InlineActivityState,
|
||||
type MusicActivityState,
|
||||
type SingActivityState,
|
||||
type NoActivityState,
|
||||
type BlobbiReactionState,
|
||||
type SelectedTrack,
|
||||
} from './lib/blobbi-activity-state';
|
||||
|
||||
// Re-export stat bounds from canonical source
|
||||
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
// Types
|
||||
type InventoryAction,
|
||||
type DirectAction,
|
||||
type BlobbiAction,
|
||||
type ResolvedInventoryItem,
|
||||
type EggStatPreview,
|
||||
type ItemUsabilityResult,
|
||||
type IncrementInteractionResult,
|
||||
// Constants
|
||||
ACTION_TO_ITEM_TYPE,
|
||||
ACTION_METADATA,
|
||||
DIRECT_ACTION_METADATA,
|
||||
ALL_ACTION_METADATA,
|
||||
GENERAL_ITEM_USABLE_STAGES,
|
||||
EGG_ALLOWED_ACTIONS,
|
||||
EGG_ALLOWED_INVENTORY_ACTIONS,
|
||||
EGG_ALLOWED_DIRECT_ACTIONS,
|
||||
EGG_VISIBLE_INVENTORY_ACTIONS,
|
||||
EGG_VISIBLE_ACTIONS,
|
||||
SHELL_REPAIR_KIT_ID,
|
||||
// Functions
|
||||
clampStat,
|
||||
applyStat,
|
||||
applyItemEffects,
|
||||
filterInventoryByAction,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
canUseDirectAction,
|
||||
isActionVisibleForStage,
|
||||
canUseInventoryItems,
|
||||
getStageRestrictionMessage,
|
||||
previewStatChanges,
|
||||
previewMedicineForEgg,
|
||||
previewCleanForEgg,
|
||||
hasMedicineEffectForEgg,
|
||||
hasHygieneEffectForEgg,
|
||||
canUseItemForStage,
|
||||
getActionForItem,
|
||||
incrementInteractionTaskTags,
|
||||
} from './lib/blobbi-action-utils';
|
||||
|
||||
// Daily Missions
|
||||
export { useDailyMissions } from './hooks/useDailyMissions';
|
||||
export type { UseDailyMissionsResult } from './hooks/useDailyMissions';
|
||||
export { useClaimMissionReward } from './hooks/useClaimMissionReward';
|
||||
export type { ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
|
||||
export {
|
||||
trackDailyMissionProgress,
|
||||
trackMultipleDailyMissionActions,
|
||||
} from './lib/daily-mission-tracker';
|
||||
export type {
|
||||
DailyMission,
|
||||
DailyMissionAction,
|
||||
DailyMissionDefinition,
|
||||
DailyMissionsState,
|
||||
} from './lib/daily-missions';
|
||||
|
||||
// Streak tracking
|
||||
export {
|
||||
calculateStreakUpdate,
|
||||
getStreakTagUpdates,
|
||||
needsStreakUpdate,
|
||||
getStreakStatus,
|
||||
} from './lib/blobbi-streak';
|
||||
export type {
|
||||
StreakUpdateResult,
|
||||
StreakTagUpdates,
|
||||
} from './lib/blobbi-streak';
|
||||
|
||||
export { useBlobbiCareActivity } from './hooks/useBlobbiCareActivity';
|
||||
export type {
|
||||
UseBlobbiCareActivityParams,
|
||||
CareActivityResult,
|
||||
} from './hooks/useBlobbiCareActivity';
|
||||
@@ -0,0 +1,634 @@
|
||||
// src/blobbi/actions/lib/blobbi-action-utils.ts
|
||||
|
||||
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
|
||||
// ─── Action Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Actions that consume inventory items
|
||||
*/
|
||||
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
|
||||
|
||||
/**
|
||||
* Non-inventory actions that don't consume items
|
||||
* These actions affect stats directly without using shop items.
|
||||
*/
|
||||
export type DirectAction = 'play_music' | 'sing';
|
||||
|
||||
/**
|
||||
* All Blobbi actions (inventory + direct)
|
||||
*/
|
||||
export type BlobbiAction = InventoryAction | DirectAction;
|
||||
|
||||
/**
|
||||
* Mapping from action type to allowed item categories
|
||||
*/
|
||||
export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
|
||||
feed: 'food',
|
||||
play: 'toy',
|
||||
clean: 'hygiene',
|
||||
medicine: 'medicine',
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for UI display (inventory actions)
|
||||
*/
|
||||
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
|
||||
feed: {
|
||||
label: 'Feed',
|
||||
description: 'Feed your Blobbi',
|
||||
icon: '🍎',
|
||||
},
|
||||
play: {
|
||||
label: 'Play',
|
||||
description: 'Play with your Blobbi',
|
||||
icon: '⚽',
|
||||
},
|
||||
clean: {
|
||||
label: 'Clean',
|
||||
description: 'Clean your Blobbi',
|
||||
icon: '🧼',
|
||||
},
|
||||
medicine: {
|
||||
label: 'Medicine',
|
||||
description: 'Heal your Blobbi',
|
||||
icon: '💊',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for direct actions (non-inventory)
|
||||
*/
|
||||
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
|
||||
play_music: {
|
||||
label: 'Play Music',
|
||||
description: 'Play music for your Blobbi',
|
||||
icon: '🎵',
|
||||
},
|
||||
sing: {
|
||||
label: 'Sing',
|
||||
description: 'Sing to your Blobbi',
|
||||
icon: '🎤',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined action metadata for all action types
|
||||
*/
|
||||
export const ALL_ACTION_METADATA: Record<BlobbiAction, { label: string; description: string; icon: string }> = {
|
||||
...ACTION_METADATA,
|
||||
...DIRECT_ACTION_METADATA,
|
||||
};
|
||||
|
||||
// ─── Stat Helpers ─────────────────────────────────────────────────────────────
|
||||
// STAT_MIN and STAT_MAX are imported from @/lib/blobbi (single source of truth)
|
||||
|
||||
/**
|
||||
* Clamp a stat value between STAT_MIN (1) and STAT_MAX (100).
|
||||
* Safe for undefined values (returns STAT_MIN).
|
||||
*
|
||||
* The minimum of 1 (instead of 0) ensures:
|
||||
* - Blobbi is never in an unrecoverable state
|
||||
* - Visual feedback shows critical state without being "dead"
|
||||
* - Recovery is always possible with any healing item
|
||||
*/
|
||||
export function clampStat(value: number | undefined): number {
|
||||
if (value === undefined) return STAT_MIN;
|
||||
return Math.max(STAT_MIN, Math.min(STAT_MAX, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a delta to a stat, clamping the result to STAT_MIN-STAT_MAX.
|
||||
*/
|
||||
export function applyStat(current: number | undefined, delta: number): number {
|
||||
const currentValue = current ?? STAT_MIN;
|
||||
return clampStat(currentValue + delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply item effects to current stats.
|
||||
* Returns a new partial stats object with all affected stats clamped.
|
||||
* Only modifies stats that have corresponding effects.
|
||||
*/
|
||||
export function applyItemEffects(
|
||||
currentStats: Partial<BlobbiStats>,
|
||||
effects: ItemEffect
|
||||
): Partial<BlobbiStats> {
|
||||
const newStats: Partial<BlobbiStats> = { ...currentStats };
|
||||
|
||||
if (effects.hunger !== undefined) {
|
||||
newStats.hunger = applyStat(currentStats.hunger, effects.hunger);
|
||||
}
|
||||
if (effects.happiness !== undefined) {
|
||||
newStats.happiness = applyStat(currentStats.happiness, effects.happiness);
|
||||
}
|
||||
if (effects.energy !== undefined) {
|
||||
newStats.energy = applyStat(currentStats.energy, effects.energy);
|
||||
}
|
||||
if (effects.hygiene !== undefined) {
|
||||
newStats.hygiene = applyStat(currentStats.hygiene, effects.hygiene);
|
||||
}
|
||||
if (effects.health !== undefined) {
|
||||
newStats.health = applyStat(currentStats.health, effects.health);
|
||||
}
|
||||
|
||||
return newStats;
|
||||
}
|
||||
|
||||
// ─── Egg-Specific Item Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Shell Repair Kit is a special medicine item only usable by eggs.
|
||||
*/
|
||||
export const SHELL_REPAIR_KIT_ID = 'med_shell_repair';
|
||||
|
||||
/**
|
||||
* Result of checking if an item can be used by a specific Blobbi stage.
|
||||
*/
|
||||
export interface ItemUsabilityResult {
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific item can be used by a companion at the given stage.
|
||||
*
|
||||
* This is the centralized item usability logic:
|
||||
* - Shell Repair Kit: Only usable by eggs
|
||||
* - Food items: Only usable by baby/adult (not eggs)
|
||||
* - Toy items: Only usable by baby/adult (not eggs)
|
||||
* - Medicine items (except Shell Repair Kit): Usable by all stages with health effect
|
||||
* - Hygiene items: Usable by all stages
|
||||
*
|
||||
* @param itemId - The shop item ID
|
||||
* @param stage - The companion's life stage
|
||||
* @returns Object with canUse boolean and optional reason string
|
||||
*/
|
||||
export function canUseItemForStage(
|
||||
itemId: string,
|
||||
stage: 'egg' | 'baby' | 'adult'
|
||||
): ItemUsabilityResult {
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) {
|
||||
return { canUse: false, reason: 'Item not found' };
|
||||
}
|
||||
|
||||
const isEgg = stage === 'egg';
|
||||
|
||||
// Shell Repair Kit special case: only for eggs
|
||||
if (itemId === SHELL_REPAIR_KIT_ID) {
|
||||
if (!isEgg) {
|
||||
return { canUse: false, reason: 'Only usable for eggs' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Food items: not usable by eggs
|
||||
if (shopItem.type === 'food') {
|
||||
if (isEgg) {
|
||||
return { canUse: false, reason: 'Eggs cannot eat food' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Toy items: not usable by eggs
|
||||
if (shopItem.type === 'toy') {
|
||||
if (isEgg) {
|
||||
return { canUse: false, reason: 'Eggs cannot use toys' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Medicine items (except Shell Repair Kit): check for health effect
|
||||
if (shopItem.type === 'medicine') {
|
||||
if (!hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
return { canUse: false, reason: 'This medicine has no effect' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Hygiene items: all stages can use
|
||||
if (shopItem.type === 'hygiene') {
|
||||
if (!hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
|
||||
return { canUse: false, reason: 'This item has no cleaning effect' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the action type for a given item.
|
||||
*/
|
||||
export function getActionForItem(itemId: string): InventoryAction | null {
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) return null;
|
||||
|
||||
const typeToAction: Record<string, InventoryAction> = {
|
||||
food: 'feed',
|
||||
toy: 'play',
|
||||
hygiene: 'clean',
|
||||
medicine: 'medicine',
|
||||
};
|
||||
|
||||
return typeToAction[shopItem.type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a medicine item has any effect on an egg.
|
||||
*
|
||||
* Eggs use the standard 3-stat model:
|
||||
* - health
|
||||
* - hygiene
|
||||
* - happiness
|
||||
*
|
||||
* Medicine with a health effect will directly affect the egg's health stat.
|
||||
*/
|
||||
export function hasMedicineEffectForEgg(effects: ItemEffect | undefined): boolean {
|
||||
if (!effects) return false;
|
||||
return effects.health !== undefined && effects.health !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hygiene item has any effect on an egg.
|
||||
* Hygiene items with a hygiene effect will directly affect the egg's hygiene stat.
|
||||
*/
|
||||
export function hasHygieneEffectForEgg(effects: ItemEffect | undefined): boolean {
|
||||
if (!effects) return false;
|
||||
return effects.hygiene !== undefined && effects.hygiene !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item has a happiness effect for an egg.
|
||||
* Some items (like bubble bath) give happiness bonus in addition to primary effects.
|
||||
*/
|
||||
export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boolean {
|
||||
if (!effects) return false;
|
||||
return effects.happiness !== undefined && effects.happiness !== 0;
|
||||
}
|
||||
|
||||
// ─── Inventory Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved inventory item with shop metadata
|
||||
*/
|
||||
export interface ResolvedInventoryItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
type: ShopItemCategory;
|
||||
effect?: ItemEffect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for filtering inventory by action
|
||||
*/
|
||||
export interface FilterInventoryOptions {
|
||||
/** Companion stage - used to filter items by egg-compatible effects */
|
||||
stage?: 'egg' | 'baby' | 'adult';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter inventory items by action type.
|
||||
* Returns resolved items with shop metadata.
|
||||
*
|
||||
* Filtering rules:
|
||||
* - Only items matching the action's item type are included
|
||||
* - Shell Repair Kit only appears in medicine modal for eggs
|
||||
* - For eggs: only items with egg-compatible effects are returned
|
||||
* - medicine action: only items with health effect
|
||||
* - clean action: only items with hygiene or happiness effect
|
||||
*/
|
||||
export function filterInventoryByAction(
|
||||
storage: StorageItem[],
|
||||
action: InventoryAction,
|
||||
options: FilterInventoryOptions = {}
|
||||
): ResolvedInventoryItem[] {
|
||||
const allowedType = ACTION_TO_ITEM_TYPE[action];
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
const isEgg = options.stage === 'egg';
|
||||
|
||||
for (const storageItem of storage) {
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
if (shopItem.type !== allowedType) continue;
|
||||
if (storageItem.quantity <= 0) continue;
|
||||
|
||||
// Shell Repair Kit: only show for eggs in medicine modal
|
||||
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For eggs, filter items by egg-compatible effects
|
||||
if (isEgg) {
|
||||
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
continue; // Skip medicine without health effect
|
||||
}
|
||||
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
|
||||
continue; // Skip hygiene items without hygiene or happiness effect
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
name: shopItem.name,
|
||||
icon: shopItem.icon,
|
||||
type: shopItem.type,
|
||||
effect: shopItem.effect,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement item quantity in storage array.
|
||||
* If quantity becomes 0, removes the item entirely.
|
||||
* Returns a new storage array (immutable).
|
||||
*/
|
||||
export function decrementStorageItem(
|
||||
storage: StorageItem[],
|
||||
itemId: string,
|
||||
amount = 1
|
||||
): StorageItem[] {
|
||||
const result: StorageItem[] = [];
|
||||
|
||||
for (const item of storage) {
|
||||
if (item.itemId !== itemId) {
|
||||
result.push(item);
|
||||
continue;
|
||||
}
|
||||
const newQuantity = item.quantity - amount;
|
||||
if (newQuantity > 0) {
|
||||
result.push({ ...item, quantity: newQuantity });
|
||||
}
|
||||
// If newQuantity <= 0, we don't add it (remove item)
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stages that can use general inventory items (food, toys, hygiene)
|
||||
*/
|
||||
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
|
||||
|
||||
/**
|
||||
* Inventory actions that are allowed for eggs.
|
||||
* Eggs can use: medicine (health), clean (hygiene)
|
||||
*/
|
||||
export const EGG_ALLOWED_INVENTORY_ACTIONS: InventoryAction[] = ['medicine', 'clean'];
|
||||
|
||||
/**
|
||||
* Direct actions that are allowed for eggs.
|
||||
* All direct actions work on eggs.
|
||||
*/
|
||||
export const EGG_ALLOWED_DIRECT_ACTIONS: DirectAction[] = ['play_music', 'sing'];
|
||||
|
||||
/**
|
||||
* Inventory actions visible in the egg UI.
|
||||
* Note: feed, play, sleep are hidden in the UI for eggs but not hard-blocked.
|
||||
*/
|
||||
export const EGG_VISIBLE_INVENTORY_ACTIONS: InventoryAction[] = ['clean', 'medicine'];
|
||||
|
||||
/**
|
||||
* All actions visible in the egg UI.
|
||||
*/
|
||||
export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_music', 'sing'];
|
||||
|
||||
/**
|
||||
* @deprecated Use EGG_ALLOWED_INVENTORY_ACTIONS instead
|
||||
*/
|
||||
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
|
||||
|
||||
/**
|
||||
* Check if a companion can use a specific inventory action.
|
||||
*
|
||||
* Note: This function no longer hard-blocks egg actions at the domain layer.
|
||||
* UI visibility is handled separately by `isActionVisibleForStage()`.
|
||||
* The domain layer allows all actions - UI chooses what to show.
|
||||
*/
|
||||
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
|
||||
// All stages can technically use all inventory actions at the domain layer.
|
||||
// UI filtering determines what actions are shown to users.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a companion can use a specific direct action.
|
||||
* Direct actions (play_music, sing) are available for all stages.
|
||||
*/
|
||||
export function canUseDirectAction(_companion: BlobbiCompanion, _action: DirectAction): boolean {
|
||||
// All stages can use direct actions
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action should be visible in the UI for a given stage.
|
||||
* This is for UI filtering only - some actions are hidden but not blocked.
|
||||
*/
|
||||
export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action: BlobbiAction): boolean {
|
||||
if (stage === 'egg') {
|
||||
return EGG_VISIBLE_ACTIONS.includes(action);
|
||||
}
|
||||
return true; // baby and adult see all actions
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a companion can use general inventory items (feed, play, clean).
|
||||
* Eggs cannot use food, toys, or hygiene items.
|
||||
* @deprecated Use canUseAction(companion, action) for action-specific checks
|
||||
*/
|
||||
export function canUseInventoryItems(companion: BlobbiCompanion): boolean {
|
||||
return GENERAL_ITEM_USABLE_STAGES.includes(companion.stage as typeof GENERAL_ITEM_USABLE_STAGES[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message explaining why an action can't be used.
|
||||
*/
|
||||
export function getStageRestrictionMessage(companion: BlobbiCompanion, action?: InventoryAction): string | null {
|
||||
if (companion.stage === 'egg') {
|
||||
if (action && EGG_ALLOWED_INVENTORY_ACTIONS.includes(action)) {
|
||||
return null; // Medicine and clean are allowed for eggs
|
||||
}
|
||||
return 'Eggs cannot use this item. Wait for your Blobbi to hatch!';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Stats Preview ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Preview stats after applying an item's effects.
|
||||
* Useful for showing the user what will happen before confirming.
|
||||
*/
|
||||
export function previewStatChanges(
|
||||
currentStats: Partial<BlobbiStats>,
|
||||
effects: ItemEffect | undefined
|
||||
): Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> {
|
||||
if (!effects) return [];
|
||||
|
||||
const changes: Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> = [];
|
||||
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'energy', 'hygiene', 'health'];
|
||||
|
||||
for (const stat of statKeys) {
|
||||
const delta = effects[stat];
|
||||
if (delta !== undefined && delta !== 0) {
|
||||
const current = currentStats[stat] ?? 0;
|
||||
const after = clampStat(current + delta);
|
||||
changes.push({ stat, current, after, delta });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview stat change for an egg.
|
||||
* Eggs use the 3-stat model: health, hygiene, happiness.
|
||||
*/
|
||||
export type EggStatPreview = { stat: 'health' | 'hygiene' | 'happiness'; current: number; after: number; delta: number };
|
||||
|
||||
/**
|
||||
* Preview medicine effects for an egg.
|
||||
* Medicine directly affects the egg's health stat.
|
||||
*/
|
||||
export function previewMedicineForEgg(
|
||||
currentHealth: number | undefined,
|
||||
effects: ItemEffect | undefined
|
||||
): EggStatPreview[] {
|
||||
if (!effects || effects.health === undefined || effects.health === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const current = currentHealth ?? 100;
|
||||
const delta = effects.health;
|
||||
const after = clampStat(current + delta);
|
||||
|
||||
return [{ stat: 'health', current, after, delta }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview clean (hygiene) effects for an egg.
|
||||
* Hygiene items directly affect the egg's hygiene stat.
|
||||
* May also include happiness bonus if the item has one.
|
||||
*/
|
||||
export function previewCleanForEgg(
|
||||
currentStats: { hygiene?: number; happiness?: number },
|
||||
effects: ItemEffect | undefined
|
||||
): EggStatPreview[] {
|
||||
if (!effects) return [];
|
||||
|
||||
const results: EggStatPreview[] = [];
|
||||
|
||||
// Hygiene effect
|
||||
if (effects.hygiene !== undefined && effects.hygiene !== 0) {
|
||||
const current = currentStats.hygiene ?? 100;
|
||||
const delta = effects.hygiene;
|
||||
const after = clampStat(current + delta);
|
||||
results.push({ stat: 'hygiene', current, after, delta });
|
||||
}
|
||||
|
||||
// Happiness bonus (some hygiene items like bubble bath give happiness)
|
||||
if (effects.happiness !== undefined && effects.happiness !== 0) {
|
||||
const current = currentStats.happiness ?? 100;
|
||||
const delta = effects.happiness;
|
||||
const after = clampStat(current + delta);
|
||||
results.push({ stat: 'happiness', current, after, delta });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Interaction Task Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/** Enable debug logging in development only */
|
||||
const DEBUG_INTERACTION_TASK = import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* Result of incrementing interaction task tags
|
||||
*/
|
||||
export interface IncrementInteractionResult {
|
||||
/** Updated tags array */
|
||||
updatedTags: string[][];
|
||||
/** New interaction count after increment */
|
||||
newCount: number;
|
||||
/** Whether the task is now complete */
|
||||
isCompleted: boolean;
|
||||
/** Previous count before increment */
|
||||
previousCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the interaction task counter in the tags array.
|
||||
*
|
||||
* This is used by both useBlobbiDirectAction and useBlobbiUseInventoryItem
|
||||
* to track progress on interaction tasks for both hatch and evolve.
|
||||
*
|
||||
* CRITICAL: This function is called during actual user actions (not retroactive sync).
|
||||
* It always increments by 1 because each call represents a real interaction.
|
||||
*
|
||||
* Tag format:
|
||||
* - Progress: ["task", "interactions:N"]
|
||||
* - Completion: ["task_completed", "interactions"]
|
||||
*
|
||||
* Idempotency notes:
|
||||
* - This is NOT idempotent by design - each call = one interaction
|
||||
* - Duplicate task_completed tags are prevented by filtering before add
|
||||
* - Multiple task:interactions tags are prevented by filtering before add
|
||||
*
|
||||
* @param currentTags - Current tags array from the Blobbi state
|
||||
* @param requiredInteractions - Threshold for completion (7 for hatch, 21 for evolve)
|
||||
* @returns Updated tags array with incremented interaction count
|
||||
*/
|
||||
export function incrementInteractionTaskTags(
|
||||
currentTags: string[][],
|
||||
requiredInteractions: number
|
||||
): IncrementInteractionResult {
|
||||
// Get current interaction count from task tags
|
||||
const interactionTag = currentTags.find(tag =>
|
||||
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
|
||||
);
|
||||
const previousCount = interactionTag
|
||||
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
|
||||
: 0;
|
||||
const newCount = previousCount + 1;
|
||||
|
||||
// Check if already completed (task_completed tag exists)
|
||||
const alreadyCompleted = currentTags.some(tag =>
|
||||
tag[0] === 'task_completed' && tag[1] === 'interactions'
|
||||
);
|
||||
|
||||
// Remove old interaction task tag (prevent duplicates) and add new one
|
||||
let updatedTags = currentTags.filter(tag =>
|
||||
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
|
||||
);
|
||||
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
|
||||
|
||||
// Mark as completed if reached required count AND not already marked
|
||||
const isCompleted = newCount >= requiredInteractions;
|
||||
if (isCompleted && !alreadyCompleted) {
|
||||
// Only add if not already present (handled by filter, but double-check)
|
||||
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
|
||||
}
|
||||
|
||||
if (DEBUG_INTERACTION_TASK) {
|
||||
console.log('[InteractionTask] Increment:', {
|
||||
previousCount,
|
||||
newCount,
|
||||
requiredInteractions,
|
||||
isCompleted,
|
||||
alreadyCompleted,
|
||||
addedCompletionTag: isCompleted && !alreadyCompleted,
|
||||
});
|
||||
}
|
||||
|
||||
return { updatedTags, newCount, isCompleted, previousCount };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// src/blobbi/actions/lib/blobbi-activity-state.ts
|
||||
|
||||
import type { SelectedTrack } from '../components/PlayMusicModal';
|
||||
|
||||
/**
|
||||
* Types of inline activities that can be displayed in BlobbiPage
|
||||
*/
|
||||
export type InlineActivityType = 'none' | 'music' | 'sing';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { SelectedTrack } from '../components/PlayMusicModal';
|
||||
|
||||
/**
|
||||
* State for the music inline activity
|
||||
*/
|
||||
export interface MusicActivityState {
|
||||
type: 'music';
|
||||
selection: SelectedTrack;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the sing inline activity
|
||||
*/
|
||||
export interface SingActivityState {
|
||||
type: 'sing';
|
||||
}
|
||||
|
||||
/**
|
||||
* No active inline activity
|
||||
*/
|
||||
export interface NoActivityState {
|
||||
type: 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all inline activity states
|
||||
*/
|
||||
export type InlineActivityState =
|
||||
| NoActivityState
|
||||
| MusicActivityState
|
||||
| SingActivityState;
|
||||
|
||||
/**
|
||||
* Blobbi reaction state - indicates how Blobbi should visually react
|
||||
*/
|
||||
export type BlobbiReactionState =
|
||||
| 'idle' // No special reaction
|
||||
| 'listening' // Music is playing, Blobbi is listening
|
||||
| 'swaying' // Blobbi is swaying to music
|
||||
| 'singing' // User is singing, Blobbi is engaged
|
||||
| 'happy'; // General happy reaction
|
||||
|
||||
/**
|
||||
* Helper to create a music activity state
|
||||
*/
|
||||
export function createMusicActivity(selection: SelectedTrack): MusicActivityState {
|
||||
return {
|
||||
type: 'music',
|
||||
selection,
|
||||
isPublished: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a sing activity state
|
||||
*/
|
||||
export function createSingActivity(): SingActivityState {
|
||||
return {
|
||||
type: 'sing',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create no activity state
|
||||
*/
|
||||
export function createNoActivity(): NoActivityState {
|
||||
return {
|
||||
type: 'none',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// src/blobbi/actions/lib/blobbi-random-lyrics.ts
|
||||
|
||||
/**
|
||||
* Random lyrics for the Sing action.
|
||||
* These are fun, simple lyrics that users can sing to their Blobbi.
|
||||
*/
|
||||
|
||||
export interface LyricsEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of placeholder lyrics for singing to a Blobbi.
|
||||
* Simple, fun, and appropriate for all ages.
|
||||
*/
|
||||
export const BLOBBI_LYRICS: LyricsEntry[] = [
|
||||
{
|
||||
id: 'lullaby-1',
|
||||
title: 'Blobbi Lullaby',
|
||||
lines: [
|
||||
'Little Blobbi, close your eyes,',
|
||||
'Dream of stars up in the skies.',
|
||||
'Safe and warm, you drift away,',
|
||||
"We'll play again another day.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'happy-song-1',
|
||||
title: 'Happy Blobbi Song',
|
||||
lines: [
|
||||
'Blobbi, Blobbi, jump around!',
|
||||
"You're the happiest friend I've found!",
|
||||
'Dancing, playing, full of cheer,',
|
||||
"I'm so glad that you are here!",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'adventure-1',
|
||||
title: 'Adventure Time',
|
||||
lines: [
|
||||
"Let's go on an adventure today,",
|
||||
'Through the clouds and far away!',
|
||||
'Mountains high and valleys deep,',
|
||||
'Memories to always keep.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'breakfast-song',
|
||||
title: 'Breakfast Song',
|
||||
lines: [
|
||||
'Wake up, wake up, sleepy head,',
|
||||
"Time to get out of your bed!",
|
||||
"Breakfast's waiting, fresh and yummy,",
|
||||
'Food to fill your happy tummy!',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rainy-day',
|
||||
title: 'Rainy Day',
|
||||
lines: [
|
||||
'Pitter patter on the roof,',
|
||||
'Rainy days can be so nice.',
|
||||
"We'll stay cozy, me and you,",
|
||||
'Watching raindrops, one by two.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sunshine-song',
|
||||
title: 'Sunshine Song',
|
||||
lines: [
|
||||
'Good morning, sunshine, bright and warm,',
|
||||
'A brand new day is being born!',
|
||||
'Blue sky smiling down on me,',
|
||||
'Happy as can be, so free!',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bedtime-1',
|
||||
title: 'Bedtime Blues',
|
||||
lines: [
|
||||
'The moon is up, the stars are bright,',
|
||||
'Time to say a soft goodnight.',
|
||||
'Snuggle up and close your eyes,',
|
||||
'Sweet dreams under starry skies.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'play-time',
|
||||
title: 'Play Time',
|
||||
lines: [
|
||||
"Bounce and jump and run around,",
|
||||
"Spin and twirl without a sound!",
|
||||
"Playing games is so much fun,",
|
||||
"Laughing underneath the sun!",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a random lyrics entry.
|
||||
*/
|
||||
export function getRandomLyrics(): LyricsEntry {
|
||||
const index = Math.floor(Math.random() * BLOBBI_LYRICS.length);
|
||||
return BLOBBI_LYRICS[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available lyrics entries.
|
||||
*/
|
||||
export function getAllLyrics(): LyricsEntry[] {
|
||||
return BLOBBI_LYRICS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format lyrics for display (joined with newlines).
|
||||
*/
|
||||
export function formatLyrics(lyrics: LyricsEntry): string {
|
||||
return lyrics.lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Blobbi Care Streak Management
|
||||
*
|
||||
* This module provides centralized logic for tracking care streaks on Blobbi companions.
|
||||
* A streak represents consecutive days of care activity (opening Blobbi page, performing
|
||||
* care actions, etc.).
|
||||
*
|
||||
* Streak Rules:
|
||||
* - Starts at 1 on first activity
|
||||
* - Increments when activity happens on the NEXT local calendar day
|
||||
* - Same-day activity does not increment (at most once per day)
|
||||
* - Missing 2+ days resets streak to 1
|
||||
*
|
||||
* Tags managed:
|
||||
* - care_streak: The current streak count (positive integer)
|
||||
* - care_streak_last_at: Unix timestamp (seconds) of last streak update
|
||||
* - care_streak_last_day: Local calendar day string (YYYY-MM-DD) of last update
|
||||
*/
|
||||
|
||||
import {
|
||||
getLocalDayString,
|
||||
getDaysDifference,
|
||||
type BlobbiCompanion,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of calculating a streak update.
|
||||
*/
|
||||
export interface StreakUpdateResult {
|
||||
/** Whether the streak was updated (incremented or reset) */
|
||||
wasUpdated: boolean;
|
||||
/** The new streak value */
|
||||
newStreak: number;
|
||||
/** The new timestamp for care_streak_last_at */
|
||||
newLastAt: number;
|
||||
/** The new day string for care_streak_last_day */
|
||||
newLastDay: string;
|
||||
/** Description of what happened (for debugging/logging) */
|
||||
action: 'initialized' | 'incremented' | 'reset' | 'same_day';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag updates to apply to the Blobbi event.
|
||||
* Only present if wasUpdated is true.
|
||||
* Uses index signature for compatibility with updateBlobbiTags.
|
||||
*/
|
||||
export interface StreakTagUpdates {
|
||||
care_streak: string;
|
||||
care_streak_last_at: string;
|
||||
care_streak_last_day: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// ─── Core Logic ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate what the streak should be updated to based on current state and activity.
|
||||
*
|
||||
* This is a pure function that calculates the new streak state without side effects.
|
||||
* Use this to determine if/how the streak should be updated.
|
||||
*
|
||||
* @param currentStreak - Current streak value (0 or undefined means no streak yet)
|
||||
* @param lastDay - The last day string (YYYY-MM-DD) when streak was updated, or undefined
|
||||
* @param now - Current timestamp (defaults to now)
|
||||
* @returns StreakUpdateResult describing the update
|
||||
*/
|
||||
export function calculateStreakUpdate(
|
||||
currentStreak: number | undefined,
|
||||
lastDay: string | undefined,
|
||||
now: Date = new Date()
|
||||
): StreakUpdateResult {
|
||||
const nowTimestamp = Math.floor(now.getTime() / 1000);
|
||||
const todayString = getLocalDayString(now);
|
||||
|
||||
// Case 1: No existing streak - initialize to 1
|
||||
if (currentStreak === undefined || currentStreak === 0 || !lastDay) {
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: 1,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'initialized',
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Activity on the same day - no update needed
|
||||
if (lastDay === todayString) {
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: currentStreak,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate days since last activity
|
||||
const daysMissed = getDaysDifference(lastDay, todayString);
|
||||
|
||||
// Case 3: Next day (1 day difference) - increment streak
|
||||
if (daysMissed === 1) {
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: currentStreak + 1,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'incremented',
|
||||
};
|
||||
}
|
||||
|
||||
// Case 4: Missed 2+ days - reset to 1
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: 1,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'reset',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tag updates to apply to a Blobbi event for a streak update.
|
||||
* Returns undefined if no update is needed (same day activity).
|
||||
*
|
||||
* @param companion - The current Blobbi companion state
|
||||
* @param now - Current timestamp (defaults to now)
|
||||
* @returns Tag updates to apply, or undefined if no update needed
|
||||
*/
|
||||
export function getStreakTagUpdates(
|
||||
companion: BlobbiCompanion,
|
||||
now: Date = new Date()
|
||||
): StreakTagUpdates | undefined {
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
if (!result.wasUpdated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
care_streak: result.newStreak.toString(),
|
||||
care_streak_last_at: result.newLastAt.toString(),
|
||||
care_streak_last_day: result.newLastDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a streak update is needed for the companion.
|
||||
*
|
||||
* @param companion - The current Blobbi companion state
|
||||
* @param now - Current timestamp (defaults to now)
|
||||
* @returns true if the streak should be updated
|
||||
*/
|
||||
export function needsStreakUpdate(
|
||||
companion: BlobbiCompanion,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
return result.wasUpdated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current streak status for display purposes.
|
||||
*
|
||||
* @param companion - The current Blobbi companion state
|
||||
* @returns Object with streak info for UI display
|
||||
*/
|
||||
export function getStreakStatus(companion: BlobbiCompanion): {
|
||||
streak: number;
|
||||
lastDay: string | undefined;
|
||||
isActive: boolean;
|
||||
daysSinceLastActivity: number | undefined;
|
||||
} {
|
||||
const streak = companion.careStreak ?? 0;
|
||||
const lastDay = companion.careStreakLastDay;
|
||||
const today = getLocalDayString();
|
||||
|
||||
let daysSinceLastActivity: number | undefined;
|
||||
let isActive = false;
|
||||
|
||||
if (lastDay) {
|
||||
daysSinceLastActivity = getDaysDifference(lastDay, today);
|
||||
// Streak is "active" if we've had activity today or yesterday
|
||||
isActive = daysSinceLastActivity <= 1;
|
||||
}
|
||||
|
||||
return {
|
||||
streak,
|
||||
lastDay,
|
||||
isActive,
|
||||
daysSinceLastActivity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// src/blobbi/actions/lib/blobbi-track-catalog.ts
|
||||
|
||||
/**
|
||||
* Blobbi Track Catalog
|
||||
*
|
||||
* Music tracks for the Blobbi "Play Music" action.
|
||||
* All tracks are hosted on remote Blossom servers and streamed on-demand.
|
||||
*
|
||||
* ## Adding New Tracks
|
||||
*
|
||||
* 1. Convert the audio file to M4A (AAC-LC):
|
||||
* `ffmpeg -i input.m4a -c:a aac -b:a 64k -ar 48000 output.m4a`
|
||||
* 2. Upload the M4A file to a Blossom server
|
||||
* 3. Add a new entry to `BLOBBI_TRACK_CATALOG` below
|
||||
* 4. Set `url` to the full Blossom URL
|
||||
* 5. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
|
||||
*
|
||||
* ## Supported Formats
|
||||
*
|
||||
* M4A (AAC-LC) is required for iOS/Safari compatibility and small file size.
|
||||
*/
|
||||
|
||||
export interface BlobbiTrack {
|
||||
/** Unique identifier for the track (used in state/events) */
|
||||
id: string;
|
||||
/** Display title shown in the UI */
|
||||
title: string;
|
||||
/** Artist or source attribution */
|
||||
artist: string;
|
||||
/** Full URL to the remote audio file (Blossom server) */
|
||||
url: string;
|
||||
/** Duration in seconds (for display, get via ffprobe) */
|
||||
durationSeconds: number;
|
||||
/** Optional cover art URL */
|
||||
coverArt?: string;
|
||||
/** Optional tags for categorization/filtering */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Blobbi track catalog.
|
||||
*
|
||||
* All tracks are royalty-free/Creative Commons licensed.
|
||||
* Audio files hosted on remote Blossom servers.
|
||||
*/
|
||||
export const BLOBBI_TRACK_CATALOG: BlobbiTrack[] = [
|
||||
{
|
||||
id: 'nap_in_the_meadow',
|
||||
title: 'Nap in the Meadow',
|
||||
artist: 'Chilltape FM',
|
||||
url: 'https://blossom.ditto.pub/6be1c95e879187f83af2a661ccac2bd96196f7bc334af44529ede6270b2811fc.m4a',
|
||||
durationSeconds: 240, // 4:00
|
||||
tags: ['relaxing', 'nature'],
|
||||
},
|
||||
{
|
||||
id: 'happy_kids',
|
||||
title: 'Happy Kids',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
url: 'https://blossom.ditto.pub/94d49abd178aa8afb14737a55e0a7143f6b337f618d74858d011232bb2db845d.m4a',
|
||||
durationSeconds: 129, // 2:09
|
||||
tags: ['upbeat', 'fun'],
|
||||
},
|
||||
{
|
||||
id: 'soft_piano',
|
||||
title: 'Soft Piano',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
url: 'https://blossom.ditto.pub/5367242d3dc555c77f5c637fd153df1166708a24c5a4c222bb4dcaeabf740743.m4a',
|
||||
durationSeconds: 124, // 2:04
|
||||
tags: ['calming', 'sleep'],
|
||||
},
|
||||
{
|
||||
id: 'epic_sacred_light',
|
||||
title: 'Epic Sacred Light',
|
||||
artist: 'Ura Megis',
|
||||
url: 'https://blossom.dreamith.to/c22953791d686605958165fd44a84cd7d9fd3d4423ebf786e47891ed3a82c6db.m4a',
|
||||
durationSeconds: 223, // 3:43
|
||||
tags: ['energetic', 'adventure'],
|
||||
},
|
||||
{
|
||||
id: 'split_memories',
|
||||
title: 'Split Memories',
|
||||
artist: 'ido berg',
|
||||
url: 'https://blossom.ditto.pub/57ba2e2122a732449880ae531d4bfac9a580bc19693c7dda735afbfa336b35fe.m4a',
|
||||
durationSeconds: 153, // 2:33
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
{
|
||||
id: 'minhas_mensagens',
|
||||
title: 'Minhas Mensagens',
|
||||
artist: 'PReis',
|
||||
url: 'https://blossom.ditto.pub/0945064dc8f946f3392be23629b166e72090cafca7cca865a20b5395dd83ff46.m4a',
|
||||
durationSeconds: 248, // 4:08
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a track by ID from the catalog
|
||||
*/
|
||||
export function getTrackById(id: string): BlobbiTrack | undefined {
|
||||
return BLOBBI_TRACK_CATALOG.find(track => track.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracks from the catalog
|
||||
*/
|
||||
export function getAllTracks(): BlobbiTrack[] {
|
||||
return BLOBBI_TRACK_CATALOG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to MM:SS string
|
||||
*/
|
||||
export function formatTrackDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateActionXP,
|
||||
calculateInventoryActionXP,
|
||||
applyXPGain,
|
||||
getXPGainSummary,
|
||||
formatXPGain,
|
||||
getXPGainMessage,
|
||||
ACTION_XP,
|
||||
INVENTORY_ACTION_XP,
|
||||
DIRECT_ACTION_XP,
|
||||
} from './blobbi-xp';
|
||||
|
||||
describe('calculateActionXP', () => {
|
||||
it('returns the correct XP for each inventory action', () => {
|
||||
expect(calculateActionXP('feed')).toBe(5);
|
||||
expect(calculateActionXP('play')).toBe(8);
|
||||
expect(calculateActionXP('clean')).toBe(6);
|
||||
expect(calculateActionXP('medicine')).toBe(10);
|
||||
});
|
||||
|
||||
it('returns the correct XP for each direct action', () => {
|
||||
expect(calculateActionXP('play_music')).toBe(7);
|
||||
expect(calculateActionXP('sing')).toBe(9);
|
||||
});
|
||||
|
||||
it('returns 0 for an unknown action', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(calculateActionXP('unknown' as any)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateInventoryActionXP', () => {
|
||||
it('returns base XP for quantity 1', () => {
|
||||
expect(calculateInventoryActionXP('feed', 1)).toBe(5);
|
||||
expect(calculateInventoryActionXP('medicine', 1)).toBe(10);
|
||||
});
|
||||
|
||||
it('multiplies XP by quantity', () => {
|
||||
expect(calculateInventoryActionXP('feed', 3)).toBe(15);
|
||||
expect(calculateInventoryActionXP('play', 5)).toBe(40);
|
||||
});
|
||||
|
||||
it('defaults to quantity 1 when not specified', () => {
|
||||
expect(calculateInventoryActionXP('clean')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns 0 for quantity less than 1', () => {
|
||||
expect(calculateInventoryActionXP('feed', 0)).toBe(0);
|
||||
expect(calculateInventoryActionXP('feed', -1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyXPGain', () => {
|
||||
it('adds XP to a current value', () => {
|
||||
expect(applyXPGain(100, 25)).toBe(125);
|
||||
});
|
||||
|
||||
it('treats undefined current XP as 0', () => {
|
||||
expect(applyXPGain(undefined, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('never returns a negative value', () => {
|
||||
expect(applyXPGain(5, -20)).toBe(0);
|
||||
expect(applyXPGain(0, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles zero XP gain', () => {
|
||||
expect(applyXPGain(50, 0)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getXPGainSummary', () => {
|
||||
it('returns the correct xpGained and quantity', () => {
|
||||
const result = getXPGainSummary('feed', 3);
|
||||
expect(result).toEqual({ xpGained: 15, quantity: 3 });
|
||||
});
|
||||
|
||||
it('defaults quantity to 1', () => {
|
||||
const result = getXPGainSummary('sing');
|
||||
expect(result).toEqual({ xpGained: 9, quantity: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatXPGain', () => {
|
||||
it('formats positive XP as "+N XP"', () => {
|
||||
expect(formatXPGain(15)).toBe('+15 XP');
|
||||
expect(formatXPGain(1)).toBe('+1 XP');
|
||||
});
|
||||
|
||||
it('returns empty string for zero or negative XP', () => {
|
||||
expect(formatXPGain(0)).toBe('');
|
||||
expect(formatXPGain(-5)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getXPGainMessage', () => {
|
||||
it('formats a message with action and XP earned', () => {
|
||||
expect(getXPGainMessage('feed', 5)).toBe('+5 XP earned!');
|
||||
});
|
||||
|
||||
it('includes total when provided', () => {
|
||||
expect(getXPGainMessage('feed', 5, 105)).toBe('+5 XP earned! Total: 105 XP');
|
||||
});
|
||||
|
||||
it('returns empty string for zero or negative XP', () => {
|
||||
expect(getXPGainMessage('feed', 0)).toBe('');
|
||||
expect(getXPGainMessage('feed', -1)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XP constants', () => {
|
||||
it('ACTION_XP contains all inventory and direct actions', () => {
|
||||
for (const action of Object.keys(INVENTORY_ACTION_XP)) {
|
||||
expect(ACTION_XP).toHaveProperty(action);
|
||||
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
|
||||
INVENTORY_ACTION_XP[action as keyof typeof INVENTORY_ACTION_XP],
|
||||
);
|
||||
}
|
||||
for (const action of Object.keys(DIRECT_ACTION_XP)) {
|
||||
expect(ACTION_XP).toHaveProperty(action);
|
||||
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
|
||||
DIRECT_ACTION_XP[action as keyof typeof DIRECT_ACTION_XP],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('all XP values are positive integers', () => {
|
||||
for (const xp of Object.values(ACTION_XP)) {
|
||||
expect(xp).toBeGreaterThan(0);
|
||||
expect(Number.isInteger(xp)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Blobbi XP (Experience Points) System
|
||||
*
|
||||
* This module defines XP values for all Blobbi care actions and provides
|
||||
* utilities for calculating and applying XP gains.
|
||||
*
|
||||
* Design Philosophy:
|
||||
* - Different actions award different XP to reflect their complexity/value
|
||||
* - XP values are balanced to encourage variety in care activities
|
||||
* - Direct actions (sing, play_music) give moderate XP as they're free
|
||||
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
|
||||
* - XP accumulates across all life stages and never resets
|
||||
*/
|
||||
|
||||
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
|
||||
|
||||
// ─── XP Values by Action ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base XP values for inventory actions (feed, play, clean, medicine).
|
||||
* These actions consume items from the player's storage.
|
||||
*/
|
||||
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
|
||||
feed: 5, // Feeding is common and essential - moderate XP
|
||||
play: 8, // Playing toys provides good interaction - higher XP
|
||||
clean: 6, // Hygiene maintenance is important - moderate-high XP
|
||||
medicine: 10, // Medicine is costly and critical - highest inventory XP
|
||||
};
|
||||
|
||||
/**
|
||||
* Base XP values for direct actions (play_music, sing).
|
||||
* These actions don't consume items - they're free activities.
|
||||
*/
|
||||
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
|
||||
play_music: 7, // Playing music is engaging - good XP
|
||||
sing: 9, // Singing requires more user effort - higher XP
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined XP lookup for all action types.
|
||||
* Use this for a unified XP calculation interface.
|
||||
*/
|
||||
export const ACTION_XP: Record<BlobbiAction, number> = {
|
||||
...INVENTORY_ACTION_XP,
|
||||
...DIRECT_ACTION_XP,
|
||||
};
|
||||
|
||||
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate XP gain for a single action.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @returns XP points earned
|
||||
*/
|
||||
export function calculateActionXP(action: BlobbiAction): number {
|
||||
return ACTION_XP[action] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total XP gain for using multiple items.
|
||||
* Each item use counts as a separate action for XP purposes.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of items used (defaults to 1)
|
||||
* @returns Total XP points earned
|
||||
*/
|
||||
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
|
||||
if (quantity < 1) return 0;
|
||||
const baseXP = INVENTORY_ACTION_XP[action] ?? 0;
|
||||
return baseXP * quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply XP gain to current experience value.
|
||||
*
|
||||
* @param currentXP - Current experience points (undefined = 0)
|
||||
* @param xpGain - XP points to add
|
||||
* @returns New total XP (never negative)
|
||||
*/
|
||||
export function applyXPGain(currentXP: number | undefined, xpGain: number): number {
|
||||
const current = currentXP ?? 0;
|
||||
const newXP = current + xpGain;
|
||||
return Math.max(0, newXP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XP gain summary for displaying to the user.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of times the action was performed (for inventory actions)
|
||||
* @returns Object with xpGained and total quantity
|
||||
*/
|
||||
export function getXPGainSummary(
|
||||
action: BlobbiAction,
|
||||
quantity: number = 1
|
||||
): { xpGained: number; quantity: number } {
|
||||
const baseXP = ACTION_XP[action] ?? 0;
|
||||
const xpGained = baseXP * quantity;
|
||||
return { xpGained, quantity };
|
||||
}
|
||||
|
||||
// ─── XP Display Utilities ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format XP gain for display in toasts/notifications.
|
||||
*
|
||||
* @param xpGained - Amount of XP gained
|
||||
* @returns Formatted string like "+15 XP"
|
||||
*/
|
||||
export function formatXPGain(xpGained: number): string {
|
||||
if (xpGained <= 0) return '';
|
||||
return `+${xpGained} XP`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a descriptive message about XP gain.
|
||||
*
|
||||
* @param action - The action that earned XP
|
||||
* @param xpGained - Amount of XP gained
|
||||
* @param newTotal - New total XP (optional, for "You now have X XP" message)
|
||||
* @returns Formatted message for user feedback
|
||||
*/
|
||||
export function getXPGainMessage(
|
||||
action: BlobbiAction,
|
||||
xpGained: number,
|
||||
newTotal?: number
|
||||
): string {
|
||||
if (xpGained <= 0) return '';
|
||||
|
||||
const xpText = formatXPGain(xpGained);
|
||||
|
||||
if (newTotal !== undefined) {
|
||||
return `${xpText} earned! Total: ${newTotal} XP`;
|
||||
}
|
||||
|
||||
return `${xpText} earned!`;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Daily Mission Tracker - Standalone progress tracking utility
|
||||
*
|
||||
* This module provides a simple way to track daily mission progress
|
||||
* without requiring React hooks or context. It directly manipulates
|
||||
* localStorage for immediate persistence.
|
||||
*
|
||||
* This approach allows action hooks (which may be called outside of
|
||||
* the daily missions hook context) to record progress.
|
||||
*/
|
||||
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMissionAction,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
updateMissionProgress,
|
||||
} from './daily-missions';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the current daily missions state from localStorage
|
||||
*/
|
||||
function readState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the daily missions state to localStorage
|
||||
*/
|
||||
function writeState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[DailyMissionTracker] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid state for today, creating one if necessary
|
||||
*/
|
||||
function ensureCurrentState(pubkey?: string): DailyMissionsState {
|
||||
const current = readState();
|
||||
|
||||
if (needsDailyReset(current)) {
|
||||
const previousCoins = current?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
|
||||
writeState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
return current!;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record progress for a daily mission action.
|
||||
* This function can be called from anywhere (hooks, event handlers, etc.)
|
||||
* and will immediately persist to localStorage.
|
||||
*
|
||||
* @param action - The action type that was performed
|
||||
* @param count - Number of times the action was performed (default: 1)
|
||||
* @param pubkey - Optional user pubkey for personalized mission selection
|
||||
*/
|
||||
export function trackDailyMissionProgress(
|
||||
action: DailyMissionAction,
|
||||
count: number = 1,
|
||||
pubkey?: string
|
||||
): void {
|
||||
const current = ensureCurrentState(pubkey);
|
||||
const updated = updateMissionProgress(current, action, count);
|
||||
writeState(updated);
|
||||
|
||||
// Dispatch a custom event so React components can re-render if needed
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to track multiple actions at once.
|
||||
* Useful when an action should count toward multiple missions.
|
||||
*
|
||||
* @param actions - Array of actions to track
|
||||
* @param pubkey - Optional user pubkey
|
||||
*/
|
||||
export function trackMultipleDailyMissionActions(
|
||||
actions: DailyMissionAction[],
|
||||
pubkey?: string
|
||||
): void {
|
||||
let current = ensureCurrentState(pubkey);
|
||||
|
||||
for (const action of actions) {
|
||||
current = updateMissionProgress(current, action, 1);
|
||||
}
|
||||
|
||||
writeState(current);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
/**
|
||||
* Daily Missions System for Blobbi
|
||||
*
|
||||
* This module defines the daily mission pool, selection logic, and types.
|
||||
* Daily missions are separate from hatch/evolve missions and provide
|
||||
* daily engagement loops with coin rewards.
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mission action types that can trigger progress
|
||||
*/
|
||||
export type DailyMissionAction =
|
||||
| 'interact' // Any interaction (feed, clean, play, etc.)
|
||||
| 'feed' // Feeding action specifically
|
||||
| 'clean' // Cleaning action specifically
|
||||
| 'sing' // Sing direct action
|
||||
| 'play_music' // Play music direct action
|
||||
| 'sleep' // Put Blobbi to sleep
|
||||
| 'take_photo' // Take a photo of Blobbi
|
||||
| 'medicine'; // Give medicine to Blobbi
|
||||
|
||||
/**
|
||||
* Blobbi stage type for filtering missions
|
||||
*/
|
||||
export type BlobbiStage = 'egg' | 'baby' | 'adult';
|
||||
|
||||
/**
|
||||
* Definition of a daily mission in the pool
|
||||
*/
|
||||
export interface DailyMissionDefinition {
|
||||
/** Unique identifier for this mission type */
|
||||
id: string;
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Description of what to do */
|
||||
description: string;
|
||||
/** Action that triggers progress */
|
||||
action: DailyMissionAction;
|
||||
/** Number of times the action must be performed */
|
||||
requiredCount: number;
|
||||
/** Coin reward for completing this mission */
|
||||
reward: number;
|
||||
/** Selection weight (higher = more likely to be selected) */
|
||||
weight: number;
|
||||
/** Required stages to show this mission (if empty/undefined, requires baby or adult) */
|
||||
requiredStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A daily mission instance with progress tracking
|
||||
*/
|
||||
export interface DailyMission extends DailyMissionDefinition {
|
||||
/** Current progress (how many times the action has been performed today) */
|
||||
currentCount: number;
|
||||
/** Whether the mission has been completed */
|
||||
completed: boolean;
|
||||
/** Whether the reward has been claimed */
|
||||
claimed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored state for daily missions (persisted in localStorage)
|
||||
*/
|
||||
export interface DailyMissionsState {
|
||||
/** The date string (YYYY-MM-DD) when these missions were generated */
|
||||
date: string;
|
||||
/** The selected missions for this day */
|
||||
missions: DailyMission[];
|
||||
/** Total coins earned from daily missions (lifetime) */
|
||||
totalCoinsEarned: number;
|
||||
/** Whether the bonus mission has been claimed today */
|
||||
bonusClaimed?: boolean;
|
||||
/** Number of rerolls remaining for today (resets daily, max 3) */
|
||||
rerollsRemaining?: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum number of mission rerolls allowed per day */
|
||||
export const MAX_DAILY_REROLLS = 3;
|
||||
|
||||
// ─── Mission Pool ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pool of available daily missions.
|
||||
* Weights determine selection frequency:
|
||||
* - High weight (10): Common missions (interact, feed, clean)
|
||||
* - Medium weight (6): Regular missions (sing, play music, sleep)
|
||||
* - Low weight (2): Uncommon missions (change shape)
|
||||
* - Rare weight (1): Rare missions (take photo)
|
||||
*/
|
||||
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BABY/ADULT ONLY MISSIONS
|
||||
// These actions are NOT available for eggs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Interact Missions (Baby/Adult only) ───────────────────────────────────
|
||||
{
|
||||
id: 'interact_3',
|
||||
title: 'Quick Care',
|
||||
description: 'Interact with your Blobbi 3 times',
|
||||
action: 'interact',
|
||||
requiredCount: 3,
|
||||
reward: 30,
|
||||
weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'interact_6',
|
||||
title: 'Attentive Caretaker',
|
||||
description: 'Interact with your Blobbi 6 times',
|
||||
action: 'interact',
|
||||
requiredCount: 6,
|
||||
reward: 50,
|
||||
weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Feed Missions (Baby/Adult only) ───────────────────────────────────────
|
||||
{
|
||||
id: 'feed_1',
|
||||
title: 'Snack Time',
|
||||
description: 'Feed your Blobbi once',
|
||||
action: 'feed',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_2',
|
||||
title: 'Hungry Blobbi',
|
||||
description: 'Feed your Blobbi 2 times',
|
||||
action: 'feed',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_3',
|
||||
title: 'Feast Day',
|
||||
description: 'Feed your Blobbi 3 times',
|
||||
action: 'feed',
|
||||
requiredCount: 3,
|
||||
reward: 60,
|
||||
weight: 5,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Sleep Missions (Baby/Adult only) ──────────────────────────────────────
|
||||
{
|
||||
id: 'sleep_1',
|
||||
title: 'Nap Time',
|
||||
description: 'Put your Blobbi to sleep',
|
||||
action: 'sleep',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Photo Missions (Baby/Adult only) ──────────────────────────────────────
|
||||
{
|
||||
id: 'take_photo_1',
|
||||
title: 'Snapshot',
|
||||
description: 'Take a polaroid photo of your Blobbi',
|
||||
action: 'take_photo',
|
||||
requiredCount: 1,
|
||||
reward: 55,
|
||||
weight: 4,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'take_photo_2',
|
||||
title: 'Photo Album',
|
||||
description: 'Take 2 photos of your Blobbi',
|
||||
action: 'take_photo',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
weight: 2,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EGG + BABY + ADULT MISSIONS
|
||||
// These actions are available for ALL stages including eggs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Clean Missions (All stages) ───────────────────────────────────────────
|
||||
{
|
||||
id: 'clean_1',
|
||||
title: 'Quick Cleanup',
|
||||
description: 'Clean your Blobbi once',
|
||||
action: 'clean',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
weight: 10,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'clean_2',
|
||||
title: 'Squeaky Clean',
|
||||
description: 'Clean your Blobbi 2 times',
|
||||
action: 'clean',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Sing Missions (All stages) ────────────────────────────────────────────
|
||||
{
|
||||
id: 'sing_1',
|
||||
title: 'Sing Along',
|
||||
description: 'Sing a song to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'sing_2',
|
||||
title: 'Karaoke Session',
|
||||
description: 'Sing 2 songs to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Play Music Missions (All stages) ──────────────────────────────────────
|
||||
{
|
||||
id: 'play_music_1',
|
||||
title: 'DJ Time',
|
||||
description: 'Play a song for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'play_music_2',
|
||||
title: 'Music Marathon',
|
||||
description: 'Play 2 songs for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Medicine Missions (All stages) ────────────────────────────────────────
|
||||
// Medicine rewards are higher since medicine costs coins to use
|
||||
{
|
||||
id: 'medicine_1',
|
||||
title: 'Health Check',
|
||||
description: 'Give medicine to your Blobbi',
|
||||
action: 'medicine',
|
||||
requiredCount: 1,
|
||||
reward: 60,
|
||||
weight: 5,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'medicine_2',
|
||||
title: 'Doctor Visit',
|
||||
description: 'Give medicine to your Blobbi 2 times',
|
||||
action: 'medicine',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the current date string in YYYY-MM-DD format (local timezone)
|
||||
*/
|
||||
export function getTodayDateString(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a seed number from a date string and optional user pubkey.
|
||||
* Used for deterministic daily mission selection.
|
||||
*/
|
||||
function generateDailySeed(dateString: string, pubkey?: string): number {
|
||||
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded random number generator (Mulberry32)
|
||||
*/
|
||||
function seededRandom(seed: number): () => number {
|
||||
return function() {
|
||||
let t = seed += 0x6D2B79F5;
|
||||
t = Math.imul(t ^ t >>> 15, t | 1);
|
||||
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mission is available for the given stages.
|
||||
* Missions with no requiredStages default to requiring baby or adult.
|
||||
*/
|
||||
function isMissionAvailableForStages(
|
||||
mission: DailyMissionDefinition,
|
||||
availableStages: BlobbiStage[]
|
||||
): boolean {
|
||||
const requiredStages = mission.requiredStages ?? ['baby', 'adult'];
|
||||
return requiredStages.some((stage) => availableStages.includes(stage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select N missions from the pool using weighted random selection.
|
||||
* Uses a seeded random generator for deterministic daily selection.
|
||||
*
|
||||
* @param count - Number of missions to select
|
||||
* @param dateString - Date string for seeding (YYYY-MM-DD)
|
||||
* @param pubkey - Optional user pubkey for seeding
|
||||
* @param availableStages - Stages the user has available (filters eligible missions)
|
||||
*/
|
||||
export function selectDailyMissions(
|
||||
count: number,
|
||||
dateString: string,
|
||||
pubkey?: string,
|
||||
availableStages?: BlobbiStage[]
|
||||
): DailyMissionDefinition[] {
|
||||
const seed = generateDailySeed(dateString, pubkey);
|
||||
const random = seededRandom(seed);
|
||||
|
||||
// Filter pool by available stages (default to baby/adult if not specified)
|
||||
const stagesToCheck = availableStages ?? ['baby', 'adult'];
|
||||
const eligibleMissions = DAILY_MISSION_POOL.filter((m) =>
|
||||
isMissionAvailableForStages(m, stagesToCheck)
|
||||
);
|
||||
|
||||
// If no missions are available for the user's stages, return empty
|
||||
if (eligibleMissions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create a copy of the eligible pool
|
||||
const available = [...eligibleMissions];
|
||||
const selected: DailyMissionDefinition[] = [];
|
||||
|
||||
while (selected.length < count && available.length > 0) {
|
||||
// Calculate total weight of remaining missions
|
||||
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
|
||||
|
||||
// Pick a random value in [0, totalWeight)
|
||||
let pick = random() * totalWeight;
|
||||
|
||||
// Find the mission that corresponds to this pick
|
||||
let selectedIndex = 0;
|
||||
for (let i = 0; i < available.length; i++) {
|
||||
pick -= available[i].weight;
|
||||
if (pick <= 0) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to selected and remove from available
|
||||
selected.push(available[selectedIndex]);
|
||||
available.splice(selectedIndex, 1);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh DailyMission from a definition
|
||||
*/
|
||||
export function createMissionFromDefinition(def: DailyMissionDefinition): DailyMission {
|
||||
return {
|
||||
...def,
|
||||
currentCount: 0,
|
||||
completed: false,
|
||||
claimed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial daily missions state for a new day
|
||||
*/
|
||||
export function createDailyMissionsState(
|
||||
dateString: string,
|
||||
pubkey?: string,
|
||||
previousTotalCoins: number = 0,
|
||||
availableStages?: BlobbiStage[]
|
||||
): DailyMissionsState {
|
||||
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
|
||||
return {
|
||||
date: dateString,
|
||||
missions: definitions.map(createMissionFromDefinition),
|
||||
totalCoinsEarned: previousTotalCoins,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the daily missions need to be reset (new day)
|
||||
*/
|
||||
export function needsDailyReset(state: DailyMissionsState | null): boolean {
|
||||
if (!state) return true;
|
||||
return state.date !== getTodayDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mission progress for a given action
|
||||
*/
|
||||
export function updateMissionProgress(
|
||||
state: DailyMissionsState,
|
||||
action: DailyMissionAction,
|
||||
incrementBy: number = 1
|
||||
): DailyMissionsState {
|
||||
const updatedMissions = state.missions.map((mission) => {
|
||||
// Skip if not the matching action or already completed
|
||||
if (mission.action !== action || mission.completed) {
|
||||
return mission;
|
||||
}
|
||||
|
||||
const newCount = Math.min(mission.currentCount + incrementBy, mission.requiredCount);
|
||||
const nowCompleted = newCount >= mission.requiredCount;
|
||||
|
||||
return {
|
||||
...mission,
|
||||
currentCount: newCount,
|
||||
completed: nowCompleted,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim reward for a completed mission
|
||||
*/
|
||||
export function claimMissionReward(
|
||||
state: DailyMissionsState,
|
||||
missionId: string
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
let coinsEarned = 0;
|
||||
|
||||
const updatedMissions = state.missions.map((mission) => {
|
||||
if (mission.id !== missionId) return mission;
|
||||
|
||||
// Can only claim if completed and not yet claimed
|
||||
if (!mission.completed || mission.claimed) return mission;
|
||||
|
||||
coinsEarned = mission.reward;
|
||||
return {
|
||||
...mission,
|
||||
claimed: true,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
|
||||
},
|
||||
coinsEarned,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total potential reward for all daily missions
|
||||
*/
|
||||
export function getTotalPotentialReward(state: DailyMissionsState): number {
|
||||
return state.missions.reduce((sum, m) => sum + m.reward, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total claimed reward for today
|
||||
*/
|
||||
export function getTodayClaimedReward(state: DailyMissionsState): number {
|
||||
return state.missions
|
||||
.filter((m) => m.claimed)
|
||||
.reduce((sum, m) => sum + m.reward, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all daily missions are completed
|
||||
*/
|
||||
export function areAllMissionsCompleted(state: DailyMissionsState): boolean {
|
||||
return state.missions.every((m) => m.completed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all daily missions are claimed
|
||||
*/
|
||||
export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
|
||||
return state.missions.every((m) => m.claimed);
|
||||
}
|
||||
|
||||
// ─── Bonus Mission ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The bonus mission that becomes available after completing all regular missions.
|
||||
* This is a special mission that rewards extra coins for daily completion.
|
||||
*/
|
||||
export const BONUS_MISSION_DEFINITION: DailyMissionDefinition = {
|
||||
id: 'bonus_daily_complete',
|
||||
title: 'Daily Champion',
|
||||
description: 'Complete all daily missions to claim this bonus reward',
|
||||
action: 'interact', // Not actually used - bonus is auto-completed
|
||||
requiredCount: 1,
|
||||
reward: 80,
|
||||
weight: 0, // Not part of random selection
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the bonus mission is available (all regular missions completed)
|
||||
*/
|
||||
export function isBonusMissionAvailable(state: DailyMissionsState): boolean {
|
||||
// Bonus is available if there are regular missions and all are completed
|
||||
return state.missions.length > 0 && areAllMissionsCompleted(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the bonus mission has been claimed today
|
||||
*/
|
||||
export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
|
||||
return state.bonusClaimed ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the bonus mission reward
|
||||
*/
|
||||
export function claimBonusMissionReward(
|
||||
state: DailyMissionsState
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
// Can only claim if bonus is available and not yet claimed
|
||||
if (!isBonusMissionAvailable(state) || isBonusMissionClaimed(state)) {
|
||||
return { state, coinsEarned: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: state.totalCoinsEarned + BONUS_MISSION_DEFINITION.reward,
|
||||
},
|
||||
coinsEarned: BONUS_MISSION_DEFINITION.reward,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Mission Reroll ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the number of rerolls remaining for today.
|
||||
* Returns MAX_DAILY_REROLLS if not set (for backward compatibility with old state).
|
||||
*/
|
||||
export function getRerollsRemaining(state: DailyMissionsState): number {
|
||||
// If rerollsRemaining is not set (old state), default to max
|
||||
if (state.rerollsRemaining === undefined || state.rerollsRemaining === null) {
|
||||
return MAX_DAILY_REROLLS;
|
||||
}
|
||||
return state.rerollsRemaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user can reroll a mission
|
||||
*/
|
||||
export function canRerollMission(state: DailyMissionsState, missionId: string): boolean {
|
||||
const rerollsRemaining = getRerollsRemaining(state);
|
||||
if (rerollsRemaining <= 0) return false;
|
||||
|
||||
// Find the mission
|
||||
const mission = state.missions.find((m) => m.id === missionId);
|
||||
if (!mission) return false;
|
||||
|
||||
// Cannot reroll completed or claimed missions
|
||||
if (mission.completed || mission.claimed) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a replacement mission that:
|
||||
* - Is not already in the current mission list
|
||||
* - Is not the mission being replaced (avoid immediately giving back the same)
|
||||
* - Respects the user's available stages
|
||||
*
|
||||
* Uses weighted random selection from eligible missions.
|
||||
*/
|
||||
export function selectReplacementMission(
|
||||
currentMissions: DailyMission[],
|
||||
missionToReplace: DailyMission,
|
||||
availableStages?: BlobbiStage[]
|
||||
): DailyMissionDefinition | null {
|
||||
// Default to baby/adult if no stages provided (most common case)
|
||||
const stagesToCheck = availableStages && availableStages.length > 0
|
||||
? availableStages
|
||||
: ['baby', 'adult'] as BlobbiStage[];
|
||||
|
||||
// Get IDs of missions that cannot be selected (current active missions)
|
||||
const excludedIds = new Set<string>();
|
||||
|
||||
// Exclude all current missions EXCEPT the one being replaced
|
||||
for (const m of currentMissions) {
|
||||
if (m.id !== missionToReplace.id) {
|
||||
excludedIds.add(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter pool to eligible missions
|
||||
const eligibleMissions = DAILY_MISSION_POOL.filter((m) => {
|
||||
// Must not be an already-active mission (except the one being replaced)
|
||||
if (excludedIds.has(m.id)) return false;
|
||||
// Must not be the same mission being replaced
|
||||
if (m.id === missionToReplace.id) return false;
|
||||
// Must be available for user's stages
|
||||
if (!isMissionAvailableForStages(m, stagesToCheck)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no eligible missions, return null
|
||||
if (eligibleMissions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use Math.random() for non-deterministic selection (rerolls should feel random)
|
||||
const totalWeight = eligibleMissions.reduce((sum, m) => sum + m.weight, 0);
|
||||
let pick = Math.random() * totalWeight;
|
||||
|
||||
for (const mission of eligibleMissions) {
|
||||
pick -= mission.weight;
|
||||
if (pick <= 0) {
|
||||
return mission;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first eligible (shouldn't happen)
|
||||
return eligibleMissions[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reroll a mission, replacing it with a new one from the pool.
|
||||
* Returns the updated state and the new mission, or null if reroll failed.
|
||||
*/
|
||||
export function rerollMission(
|
||||
state: DailyMissionsState,
|
||||
missionId: string,
|
||||
availableStages?: BlobbiStage[]
|
||||
): { state: DailyMissionsState; newMission: DailyMission } | null {
|
||||
// Check if reroll is allowed
|
||||
if (!canRerollMission(state, missionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the mission index
|
||||
const missionIndex = state.missions.findIndex((m) => m.id === missionId);
|
||||
if (missionIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldMission = state.missions[missionIndex];
|
||||
|
||||
// Select a replacement
|
||||
const replacement = selectReplacementMission(state.missions, oldMission, availableStages);
|
||||
if (!replacement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create the new mission instance
|
||||
const newMission = createMissionFromDefinition(replacement);
|
||||
|
||||
// Update the missions array
|
||||
const updatedMissions = [...state.missions];
|
||||
updatedMissions[missionIndex] = newMission;
|
||||
|
||||
// Decrement rerolls remaining
|
||||
const newRerollsRemaining = getRerollsRemaining(state) - 1;
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
rerollsRemaining: newRerollsRemaining,
|
||||
},
|
||||
newMission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Grupo das pétalas com rotação -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="10s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
|
||||
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
|
||||
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
|
||||
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
|
||||
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
|
||||
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
|
||||
</g>
|
||||
|
||||
<!-- Grupo das partículas giratórias -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="20s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.8" />
|
||||
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.6" />
|
||||
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.7" />
|
||||
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.5" />
|
||||
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.9" />
|
||||
</g>
|
||||
|
||||
<!-- Centro da flor -->
|
||||
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
|
||||
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<circle cx="88" cy="105" r="8" fill="white" />
|
||||
<circle cx="112" cy="105" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<circle cx="88" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="112" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="90" cy="103" r="2" fill="white" />
|
||||
<circle cx="114" cy="103" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 90 120 Q 100 128 110 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Bochechas -->
|
||||
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
|
||||
<!-- Gradientes -->
|
||||
<defs>
|
||||
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fed7d7" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#e0e7ff" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dcfce7" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dbeafe" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,99 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Grupo das pétalas com rotação mais lenta (ou pode ser removido completamente) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="20s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
|
||||
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
|
||||
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
|
||||
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
|
||||
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
|
||||
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
|
||||
</g>
|
||||
|
||||
<!-- Grupo das partículas giratórias -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="30s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.4" />
|
||||
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.3" />
|
||||
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.3" />
|
||||
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.2" />
|
||||
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.4" />
|
||||
</g>
|
||||
|
||||
<!-- Centro da flor -->
|
||||
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
|
||||
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Olhos dormindo -->
|
||||
<path d="M 80 105 Q 88 108 96 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 104 105 Q 112 108 120 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Boca calma -->
|
||||
<circle cx="100" cy="120" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Bochechas -->
|
||||
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
|
||||
<!-- "Zzz" dormindo -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<!-- Gradientes -->
|
||||
<defs>
|
||||
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fed7d7" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#e0e7ff" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dcfce7" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dbeafe" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,100 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main leaf body - classic leaf shape -->
|
||||
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
|
||||
fill="url(#breezyBody)" />
|
||||
|
||||
<!-- Leaf veins - central vein -->
|
||||
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
|
||||
|
||||
<!-- Side veins -->
|
||||
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Inner leaf highlight -->
|
||||
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
|
||||
fill="url(#breezyInner)" opacity="0.6" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="85" cy="90" r="10" fill="white" />
|
||||
<circle cx="115" cy="90" r="10" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="85" cy="90" r="6" fill="#1f2937" />
|
||||
<circle cx="115" cy="90" r="6" fill="#1f2937" />
|
||||
<circle cx="87" cy="88" r="3" fill="white" />
|
||||
<circle cx="117" cy="88" r="3" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 85 110 Q 100 120 115 110" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
|
||||
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
|
||||
<!-- Floating leaves with rotation groups -->
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
|
||||
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.8" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
|
||||
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.6" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
|
||||
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.7" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
|
||||
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="30%" stop-color="#4ade80" />
|
||||
<stop offset="70%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#bbf7d0" />
|
||||
<stop offset="100%" stop-color="#86efac" />
|
||||
</radialGradient>
|
||||
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#15803d" />
|
||||
<stop offset="50%" stop-color="#16a34a" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</linearGradient>
|
||||
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,95 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main leaf body -->
|
||||
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
|
||||
fill="url(#breezyBody)" />
|
||||
|
||||
<!-- Leaf veins -->
|
||||
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
|
||||
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Inner leaf highlight -->
|
||||
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
|
||||
fill="url(#breezyInner)" opacity="0.6" />
|
||||
|
||||
<!-- Olhos dormindo -->
|
||||
<path d="M 75 90 Q 85 93 95 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 105 90 Q 115 93 125 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Boca tranquila -->
|
||||
<circle cx="100" cy="110" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
|
||||
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
|
||||
<!-- Floating leaves -->
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
|
||||
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.6" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
|
||||
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
|
||||
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
|
||||
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.4" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" dormindo -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<!-- Gradientes -->
|
||||
<defs>
|
||||
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="30%" stop-color="#4ade80" />
|
||||
<stop offset="70%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#bbf7d0" />
|
||||
<stop offset="100%" stop-color="#86efac" />
|
||||
</radialGradient>
|
||||
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#15803d" />
|
||||
<stop offset="50%" stop-color="#16a34a" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</linearGradient>
|
||||
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,75 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<!-- Main cactus body -->
|
||||
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" />
|
||||
|
||||
<!-- Cactus arms -->
|
||||
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
|
||||
<rect x="120" y="110" width="20" height="35" rx="10" fill="url(#cactiArm)" />
|
||||
|
||||
<!-- Cactus ridges -->
|
||||
<line x1="92" y1="85" x2="92" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
<line x1="100" y1="85" x2="100" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
<line x1="108" y1="85" x2="108" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="90" cy="105" r="8" fill="white" />
|
||||
<circle cx="110" cy="105" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="90" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="110" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="92" cy="103" r="2" fill="white" />
|
||||
<circle cx="112" cy="103" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 92 120 Q 100 126 108 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Tiny spines -->
|
||||
<circle cx="88" cy="90" r="1" fill="#65a30d" />
|
||||
<circle cx="95" cy="95" r="1" fill="#65a30d" />
|
||||
<circle cx="105" cy="92" r="1" fill="#65a30d" />
|
||||
<circle cx="112" cy="88" r="1" fill="#65a30d" />
|
||||
<circle cx="65" cy="110" r="1" fill="#65a30d" />
|
||||
<circle cx="70" cy="120" r="1" fill="#65a30d" />
|
||||
<circle cx="125" cy="115" r="1" fill="#65a30d" />
|
||||
<circle cx="130" cy="125" r="1" fill="#65a30d" />
|
||||
|
||||
<!-- Little legs in pot -->
|
||||
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#cactiPot)" />
|
||||
<rect x="75" y="160" width="50" height="5" fill="url(#cactiPotRim)" rx="2" />
|
||||
|
||||
<!-- Blooming flower -->
|
||||
<circle cx="100" cy="75" r="12" fill="url(#cactiFlower)" />
|
||||
<circle cx="100" cy="75" r="8" fill="url(#cactiFlowerCenter)" />
|
||||
<circle cx="100" cy="75" r="4" fill="#fbbf24" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="cactiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a3e635" />
|
||||
<stop offset="30%" stop-color="#84cc16" />
|
||||
<stop offset="70%" stop-color="#65a30d" />
|
||||
<stop offset="100%" stop-color="#4d7c0f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#84cc16" />
|
||||
<stop offset="100%" stop-color="#65a30d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiPot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiPotRim" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiFlower" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiFlowerCenter" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,74 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<!-- Main cactus body -->
|
||||
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" />
|
||||
|
||||
<!-- Cactus arms -->
|
||||
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
|
||||
<rect x="120" y="110" width="20" height="35" rx="10" fill="url(#cactiArm)" />
|
||||
|
||||
<!-- Cactus ridges -->
|
||||
<line x1="92" y1="85" x2="92" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
<line x1="100" y1="85" x2="100" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
<line x1="108" y1="85" x2="108" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 82 105 Q 90 108 98 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 105 Q 110 108 118 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="120" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Tiny spines -->
|
||||
<circle cx="88" cy="90" r="1" fill="#65a30d" />
|
||||
<circle cx="95" cy="95" r="1" fill="#65a30d" />
|
||||
<circle cx="105" cy="92" r="1" fill="#65a30d" />
|
||||
<circle cx="112" cy="88" r="1" fill="#65a30d" />
|
||||
<circle cx="65" cy="110" r="1" fill="#65a30d" />
|
||||
<circle cx="70" cy="120" r="1" fill="#65a30d" />
|
||||
<circle cx="125" cy="115" r="1" fill="#65a30d" />
|
||||
<circle cx="130" cy="125" r="1" fill="#65a30d" />
|
||||
|
||||
<!-- Little legs in pot -->
|
||||
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#cactiPot)" />
|
||||
<rect x="75" y="160" width="50" height="5" fill="url(#cactiPotRim)" rx="2" />
|
||||
|
||||
<!-- Blooming flower -->
|
||||
<circle cx="100" cy="75" r="12" fill="url(#cactiFlower)" />
|
||||
<circle cx="100" cy="75" r="8" fill="url(#cactiFlowerCenter)" />
|
||||
<circle cx="100" cy="75" r="4" fill="#fbbf24" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="cactiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a3e635" />
|
||||
<stop offset="30%" stop-color="#84cc16" />
|
||||
<stop offset="70%" stop-color="#65a30d" />
|
||||
<stop offset="100%" stop-color="#4d7c0f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#84cc16" />
|
||||
<stop offset="100%" stop-color="#65a30d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiPot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiPotRim" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiFlower" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiFlowerCenter" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="cattiBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEar3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEarInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f3f4f6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiNose3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#be185d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiNoseHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fce7f3;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiTailHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="cattiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Oval upright body -->
|
||||
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
|
||||
|
||||
<!-- Triangle ears -->
|
||||
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
|
||||
<path d="M 132 72 L 142 48 L 118 62 Z" fill="url(#cattiEar3D)" />
|
||||
<path d="M 70 62 L 64 52 L 76 58 Z" fill="url(#cattiEarInner)" />
|
||||
<path d="M 130 62 L 136 52 L 124 58 Z" fill="url(#cattiEarInner)" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<ellipse cx="85" cy="100" rx="12" ry="16" fill="url(#cattiEyeWhite3D)" />
|
||||
<ellipse cx="115" cy="100" rx="12" ry="16" fill="url(#cattiEyeWhite3D)" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<ellipse cx="85" cy="100" rx="8" ry="12" fill="url(#cattiPupil3D)" />
|
||||
<ellipse cx="115" cy="100" rx="8" ry="12" fill="url(#cattiPupil3D)" />
|
||||
<ellipse cx="87" cy="97" rx="3" ry="4" fill="white" opacity="0.9" />
|
||||
<ellipse cx="117" cy="97" rx="3" ry="4" fill="white" opacity="0.9" />
|
||||
|
||||
<!-- Nose -->
|
||||
<path d="M 94 115 L 100 122 L 106 115 Z" fill="url(#cattiNose3D)" />
|
||||
<path d="M 96 116 L 100 120 L 104 116 Z" fill="url(#cattiNoseHighlight)" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 100 122 Q 88 128 82 122" stroke="url(#cattiMouth3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 100 122 Q 112 128 118 122" stroke="url(#cattiMouth3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced curved tail -->
|
||||
<path d="M 145 140 Q 165 115 158 95 Q 148 75 165 65" stroke="url(#cattiTail3D)" stroke-width="22" fill="none" stroke-linecap="round" />
|
||||
<path d="M 145 140 Q 163 117 156 97 Q 148 79 163 69" stroke="url(#cattiTailHighlight)" stroke-width="16" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced whiskers -->
|
||||
<path d="M 48 108 Q 58 110 72 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 48 118 Q 58 120 72 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 128 108 Q 138 110 152 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 128 118 Q 138 120 152 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft fur texture details -->
|
||||
<ellipse cx="75" cy="135" rx="3" ry="2" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="125" cy="130" rx="2.5" ry="2" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="90" cy="150" rx="2" ry="1.5" fill="rgba(255,255,255,0.15)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="cattiBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEar3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEarInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f3f4f6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiNose3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#be185d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiNoseHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fce7f3;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiTailHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="cattiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Oval upright body -->
|
||||
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
|
||||
|
||||
<!-- Triangle ears -->
|
||||
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
|
||||
<path d="M 132 72 L 142 48 L 118 62 Z" fill="url(#cattiEar3D)" />
|
||||
<path d="M 70 62 L 64 52 L 76 58 Z" fill="url(#cattiEarInner)" />
|
||||
<path d="M 130 62 L 136 52 L 124 58 Z" fill="url(#cattiEarInner)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 73 100 Q 85 103 97 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 103 100 Q 115 103 127 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced cat nose -->
|
||||
<path d="M 94 115 L 100 122 L 106 115 Z" fill="url(#cattiNose3D)" />
|
||||
<path d="M 96 116 L 100 120 L 104 116 Z" fill="url(#cattiNoseHighlight)" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="125" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Enhanced curved tail -->
|
||||
<path d="M 145 140 Q 165 115 158 95 Q 148 75 165 65" stroke="url(#cattiTail3D)" stroke-width="22" fill="none" stroke-linecap="round" />
|
||||
<path d="M 145 140 Q 163 117 156 97 Q 148 79 163 69" stroke="url(#cattiTailHighlight)" stroke-width="16" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced whiskers -->
|
||||
<path d="M 48 108 Q 58 110 72 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 48 118 Q 58 120 72 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 128 108 Q 138 110 152 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 128 118 Q 138 120 152 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft fur texture details -->
|
||||
<ellipse cx="75" cy="135" rx="3" ry="2" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="125" cy="130" rx="2.5" ry="2" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="90" cy="150" rx="2" ry="1.5" fill="rgba(255,255,255,0.15)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main cloud body - multiple overlapping circles -->
|
||||
<circle cx="100" cy="120" r="45" fill="url(#cloudiBody)" />
|
||||
<circle cx="75" cy="110" r="35" fill="url(#cloudiBody)" />
|
||||
<circle cx="125" cy="110" r="35" fill="url(#cloudiBody)" />
|
||||
<circle cx="85" cy="95" r="25" fill="url(#cloudiBody)" />
|
||||
<circle cx="115" cy="95" r="25" fill="url(#cloudiBody)" />
|
||||
<circle cx="100" cy="85" r="30" fill="url(#cloudiBody)" />
|
||||
|
||||
<!-- Fluffy highlights -->
|
||||
<circle cx="90" cy="100" r="20" fill="url(#cloudiHighlight)" opacity="0.6" />
|
||||
<circle cx="110" cy="105" r="18" fill="url(#cloudiHighlight)" opacity="0.5" />
|
||||
<circle cx="100" cy="90" r="15" fill="url(#cloudiHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<circle cx="88" cy="100" r="8" fill="white" />
|
||||
<circle cx="112" cy="100" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<circle cx="88" cy="100" r="5" fill="#64748b" />
|
||||
<circle cx="112" cy="100" r="5" fill="#64748b" />
|
||||
<circle cx="90" cy="98" r="2" fill="white" />
|
||||
<circle cx="114" cy="98" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 92 115 Q 100 122 108 115" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Floating raindrops -->
|
||||
<circle cx="70" cy="140" r="3" fill="url(#cloudiRain)" opacity="0.8" />
|
||||
<circle cx="130" cy="145" r="2.5" fill="url(#cloudiRain)" opacity="0.6" />
|
||||
<circle cx="85" cy="155" r="2" fill="url(#cloudiRain)" opacity="0.7" />
|
||||
<circle cx="115" cy="150" r="2.5" fill="url(#cloudiRain)" opacity="0.5" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="50%" stop-color="#f1f5f9" />
|
||||
<stop offset="100%" stop-color="#e2e8f0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.5)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cloudiRain" cx="0.5" cy="0.3">
|
||||
<stop offset="0%" stop-color="#60a5fa" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,51 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main cloud body - multiple overlapping circles -->
|
||||
<circle cx="100" cy="120" r="45" fill="url(#cloudiBody)" />
|
||||
<circle cx="75" cy="110" r="35" fill="url(#cloudiBody)" />
|
||||
<circle cx="125" cy="110" r="35" fill="url(#cloudiBody)" />
|
||||
<circle cx="85" cy="95" r="25" fill="url(#cloudiBody)" />
|
||||
<circle cx="115" cy="95" r="25" fill="url(#cloudiBody)" />
|
||||
<circle cx="100" cy="85" r="30" fill="url(#cloudiBody)" />
|
||||
|
||||
<!-- Fluffy highlights -->
|
||||
<circle cx="90" cy="100" r="20" fill="url(#cloudiHighlight)" opacity="0.6" />
|
||||
<circle cx="110" cy="105" r="18" fill="url(#cloudiHighlight)" opacity="0.5" />
|
||||
<circle cx="100" cy="90" r="15" fill="url(#cloudiHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 80 100 Q 88 103 96 100" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 104 100 Q 112 103 120 100" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#64748b" />
|
||||
|
||||
<!-- Floating raindrops with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 0,5; 0,0" dur="3s" repeatCount="indefinite" />
|
||||
<circle cx="70" cy="140" r="3" fill="url(#cloudiRain)" opacity="0.6" />
|
||||
<circle cx="130" cy="145" r="2.5" fill="url(#cloudiRain)" opacity="0.4" />
|
||||
<circle cx="85" cy="155" r="2" fill="url(#cloudiRain)" opacity="0.5" />
|
||||
<circle cx="115" cy="150" r="2.5" fill="url(#cloudiRain)" opacity="0.3" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="50%" stop-color="#f1f5f9" />
|
||||
<stop offset="100%" stop-color="#e2e8f0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.5)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cloudiRain" cx="0.5" cy="0.3">
|
||||
<stop offset="0%" stop-color="#60a5fa" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Crystal gradients -->
|
||||
<radialGradient id="crystiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f3e8ff;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#e9d5ff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="crystiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="crystiFacet1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet2" x1="1" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet3" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#34d399;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#10b981;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet4" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet5" x1="1" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" style="stop-color:#a78bfa;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet6" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fb7185;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e11d48;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="crystiEye" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e9d5ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="crystiSmile" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main crystal body - rounded hexagon shape -->
|
||||
<path d="M 100 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" />
|
||||
<path d="M 100 55 L 135 82 L 135 128 L 100 155 L 65 128 L 65 82 Z" fill="url(#crystiInner)" opacity="0.7" />
|
||||
|
||||
<!-- Crystal segments with rounded edges -->
|
||||
<path d="M 100 50 L 125 70 L 100 105 L 75 70 Z" fill="url(#crystiFacet1)" opacity="0.8" />
|
||||
<path d="M 75 70 L 100 105 L 60 80 L 60 105 Z" fill="url(#crystiFacet2)" opacity="0.7" />
|
||||
<path d="M 125 70 L 140 80 L 140 105 L 100 105 Z" fill="url(#crystiFacet3)" opacity="0.7" />
|
||||
<path d="M 60 105 L 100 105 L 75 140 L 60 130 Z" fill="url(#crystiFacet4)" opacity="0.6" />
|
||||
<path d="M 100 105 L 140 105 L 125 140 L 100 105 Z" fill="url(#crystiFacet5)" opacity="0.6" />
|
||||
<path d="M 75 140 L 100 105 L 125 140 L 100 160 Z" fill="url(#crystiFacet6)" opacity="0.8" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="88" cy="95" r="10" fill="url(#crystiEye)" />
|
||||
<circle cx="112" cy="95" r="10" fill="url(#crystiEye)" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="88" cy="95" r="6" fill="#1e1b4b" />
|
||||
<circle cx="112" cy="95" r="6" fill="#1e1b4b" />
|
||||
<circle cx="90" cy="93" r="3" fill="white" />
|
||||
<circle cx="114" cy="93" r="3" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 90 115 Q 100 123 110 115" stroke="url(#crystiSmile)" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Floating sparkles -->
|
||||
<circle cx="65" cy="65" r="2" fill="#fbbf24" opacity="0.9" />
|
||||
<circle cx="135" cy="70" r="1.5" fill="#f472b6" opacity="0.8" />
|
||||
<circle cx="70" cy="140" r="1" fill="#06b6d4" opacity="0.7" />
|
||||
<circle cx="130" cy="135" r="2" fill="#fbbf24" opacity="0.6" />
|
||||
<circle cx="50" cy="105" r="1.5" fill="#f472b6" opacity="0.8" />
|
||||
<circle cx="150" cy="110" r="1" fill="#06b6d4" opacity="0.9" />
|
||||
<circle cx="100" cy="40" r="1.5" fill="#fbbf24" opacity="0.7" />
|
||||
<circle cx="100" cy="170" r="1" fill="#f472b6" opacity="0.8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Crystal gradients -->
|
||||
<radialGradient id="crystiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f3e8ff;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#e9d5ff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="crystiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="crystiFacet1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet2" x1="1" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet3" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#34d399;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#10b981;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet4" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet5" x1="1" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" style="stop-color:#a78bfa;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet6" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fb7185;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e11d48;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="crystiEye" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e9d5ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="crystiSmile" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main crystal body - rounded hexagon shape -->
|
||||
<path d="M 100 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" />
|
||||
<path d="M 100 55 L 135 82 L 135 128 L 100 155 L 65 128 L 65 82 Z" fill="url(#crystiInner)" opacity="0.7" />
|
||||
|
||||
<!-- Crystal segments with rounded edges -->
|
||||
<path d="M 100 50 L 125 70 L 100 105 L 75 70 Z" fill="url(#crystiFacet1)" opacity="0.6" />
|
||||
<path d="M 75 70 L 100 105 L 60 80 L 60 105 Z" fill="url(#crystiFacet2)" opacity="0.5" />
|
||||
<path d="M 125 70 L 140 80 L 140 105 L 100 105 Z" fill="url(#crystiFacet3)" opacity="0.5" />
|
||||
<path d="M 60 105 L 100 105 L 75 140 L 60 130 Z" fill="url(#crystiFacet4)" opacity="0.4" />
|
||||
<path d="M 100 105 L 140 105 L 125 140 L 100 105 Z" fill="url(#crystiFacet5)" opacity="0.4" />
|
||||
<path d="M 75 140 L 100 105 L 125 140 L 100 160 Z" fill="url(#crystiFacet6)" opacity="0.6" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 78 95 Q 88 98 98 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 95 Q 112 98 122 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#8b5cf6" />
|
||||
|
||||
<!-- Floating sparkles with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="360 100 100" dur="15s" repeatCount="indefinite" />
|
||||
<circle cx="65" cy="65" r="2" fill="#fbbf24" opacity="0.6" />
|
||||
<circle cx="135" cy="70" r="1.5" fill="#f472b6" opacity="0.5" />
|
||||
<circle cx="70" cy="140" r="1" fill="#06b6d4" opacity="0.4" />
|
||||
<circle cx="130" cy="135" r="2" fill="#fbbf24" opacity="0.3" />
|
||||
</g>
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="-360 100 100" dur="20s" repeatCount="indefinite" />
|
||||
<circle cx="50" cy="105" r="1.5" fill="#f472b6" opacity="0.5" />
|
||||
<circle cx="150" cy="110" r="1" fill="#06b6d4" opacity="0.6" />
|
||||
<circle cx="100" cy="40" r="1.5" fill="#fbbf24" opacity="0.4" />
|
||||
<circle cx="100" cy="170" r="1" fill="#f472b6" opacity="0.5" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,89 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Water gradients -->
|
||||
<radialGradient id="droppiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#67e8f9;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#22d3ee;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#e0f2fe;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7dd3fc;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiHighlight" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0284c7;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiDroplet" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#7dd3fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main water drop body -->
|
||||
<path d="M 100 40 Q 100 30 100 40 Q 135 60 140 110 Q 140 150 100 165 Q 60 150 60 110 Q 65 60 100 40"
|
||||
fill="url(#droppiBody)" />
|
||||
|
||||
<!-- Inner water reflection -->
|
||||
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
|
||||
<ellipse cx="90" cy="80" rx="20" ry="25" fill="url(#droppiHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="85" cy="95" r="12" fill="white" />
|
||||
<circle cx="115" cy="95" r="12" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="85" cy="95" r="8" fill="#0891b2" />
|
||||
<circle cx="115" cy="95" r="8" fill="#0891b2" />
|
||||
<circle cx="88" cy="92" r="4" fill="white" />
|
||||
<circle cx="118" cy="92" r="4" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 115 Q 100 123 112 115" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<ellipse cx="60" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(-25 60 110)" />
|
||||
<ellipse cx="140" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(25 140 110)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="85" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
|
||||
<ellipse cx="115" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
|
||||
|
||||
<!-- Water droplets floating around - grouped with rotation -->
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
|
||||
<circle cx="-45" cy="-35" r="3" fill="url(#droppiDroplet)" opacity="0.8" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
|
||||
<circle cx="45" cy="-30" r="2.5" fill="url(#droppiDroplet)" opacity="0.6" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
|
||||
<circle cx="-50" cy="25" r="2" fill="url(#droppiDroplet)" opacity="0.7" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
|
||||
<circle cx="50" cy="20" r="2.5" fill="url(#droppiDroplet)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,88 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Water gradients -->
|
||||
<radialGradient id="droppiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#67e8f9;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#22d3ee;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#e0f2fe;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7dd3fc;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiHighlight" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0284c7;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiDroplet" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#7dd3fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main water drop body -->
|
||||
<path d="M 100 40 Q 100 30 100 40 Q 135 60 140 110 Q 140 150 100 165 Q 60 150 60 110 Q 65 60 100 40"
|
||||
fill="url(#droppiBody)" />
|
||||
|
||||
<!-- Inner water reflection -->
|
||||
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
|
||||
<ellipse cx="90" cy="80" rx="20" ry="25" fill="url(#droppiHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 73 95 Q 85 98 97 95" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 103 95 Q 115 98 127 95" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#0891b2" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<ellipse cx="60" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(-25 60 110)" />
|
||||
<ellipse cx="140" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(25 140 110)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="85" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
|
||||
<ellipse cx="115" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
|
||||
|
||||
<!-- Water droplets floating around - grouped with slower rotation -->
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="12s" repeatCount="indefinite" />
|
||||
<circle cx="-45" cy="-35" r="3" fill="url(#droppiDroplet)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="15s" repeatCount="indefinite" />
|
||||
<circle cx="45" cy="-30" r="2.5" fill="url(#droppiDroplet)" opacity="0.4" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="10s" repeatCount="indefinite" />
|
||||
<circle cx="-50" cy="25" r="2" fill="url(#droppiDroplet)" opacity="0.4" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="18s" repeatCount="indefinite" />
|
||||
<circle cx="50" cy="20" r="2.5" fill="url(#droppiDroplet)" opacity="0.3" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,76 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<!-- Flame gradients -->
|
||||
<radialGradient id="flammiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fbbf24" />
|
||||
<stop offset="30%" stop-color="#f97316" />
|
||||
<stop offset="70%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fde047" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f97316" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiCore" cx="0.5" cy="0.4">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fde047" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f97316" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#b91c1c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiEmber" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fde047" />
|
||||
<stop offset="100%" stop-color="#f97316" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Larger rotating flames -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M45 80 Q50 65 55 80 Q50 90 45 80 Z" fill="url(#flammiEmber)" opacity="0.8" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="4s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M155 85 Q160 70 165 85 Q160 95 155 85 Z" fill="url(#flammiEmber)" opacity="0.6" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="6s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M40 130 Q45 115 50 130 Q45 140 40 130 Z" fill="url(#flammiEmber)" opacity="0.7" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="7s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M160 125 Q165 110 170 125 Q165 135 160 125 Z" fill="url(#flammiEmber)" opacity="0.5" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="5s" repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Flammy Body -->
|
||||
<path d="M 100 160 Q 60 140 50 110 Q 45 80 70 60 Q 80 40 100 25 Q 120 40 130 60 Q 155 80 150 110 Q 140 140 100 160 Z" fill="url(#flammiBody)" />
|
||||
<path d="M 100 155 Q 65 138 58 115 Q 55 90 75 70 Q 82 50 100 35 Q 118 50 125 70 Q 145 90 142 115 Q 135 138 100 155 Z" fill="url(#flammiInner)" opacity="0.8" />
|
||||
<path d="M 100 145 Q 70 130 65 110 Q 62 95 80 80 Q 85 65 100 55 Q 115 65 120 80 Q 138 95 135 110 Q 130 130 100 145 Z" fill="url(#flammiCore)" opacity="0.9" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="88" cy="100" r="10" fill="white" />
|
||||
<circle cx="112" cy="100" r="10" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="88" cy="100" r="6" fill="#1f2937" />
|
||||
<circle cx="112" cy="100" r="6" fill="#1f2937" />
|
||||
<circle cx="90" cy="98" r="3" fill="white" />
|
||||
<circle cx="114" cy="98" r="3" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 115 Q 100 125 112 115" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Arms -->
|
||||
<ellipse cx="55" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(-30 55 110)" />
|
||||
<ellipse cx="145" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(30 145 110)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,75 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<!-- Flame gradients -->
|
||||
<radialGradient id="flammiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fbbf24" />
|
||||
<stop offset="30%" stop-color="#f97316" />
|
||||
<stop offset="70%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fde047" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f97316" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiCore" cx="0.5" cy="0.4">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fde047" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f97316" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#b91c1c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiEmber" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fde047" />
|
||||
<stop offset="100%" stop-color="#f97316" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Slower rotating flames for sleeping state -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M45 80 Q50 65 55 80 Q50 90 45 80 Z" fill="url(#flammiEmber)" opacity="0.5" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="8s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M155 85 Q160 70 165 85 Q160 95 155 85 Z" fill="url(#flammiEmber)" opacity="0.4" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="12s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M40 130 Q45 115 50 130 Q45 140 40 130 Z" fill="url(#flammiEmber)" opacity="0.4" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="14s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M160 125 Q165 110 170 125 Q165 135 160 125 Z" fill="url(#flammiEmber)" opacity="0.3" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="10s" repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Flammy Body -->
|
||||
<path d="M 100 160 Q 60 140 50 110 Q 45 80 70 60 Q 80 40 100 25 Q 120 40 130 60 Q 155 80 150 110 Q 140 140 100 160 Z" fill="url(#flammiBody)" />
|
||||
<path d="M 100 155 Q 65 138 58 115 Q 55 90 75 70 Q 82 50 100 35 Q 118 50 125 70 Q 145 90 142 115 Q 135 138 100 155 Z" fill="url(#flammiInner)" opacity="0.8" />
|
||||
<path d="M 100 145 Q 70 130 65 110 Q 62 95 80 80 Q 85 65 100 55 Q 115 65 120 80 Q 138 95 135 110 Q 130 130 100 145 Z" fill="url(#flammiCore)" opacity="0.9" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 78 100 Q 88 103 98 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 100 Q 112 103 122 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Arms -->
|
||||
<ellipse cx="55" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(-30 55 110)" />
|
||||
<ellipse cx="145" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(30 145 110)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="froggiBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#22c55e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#15803d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiEyeBase3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="froggiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="froggiMouthHighlight" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:rgba(255,255,255,0.1);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="froggiNostril3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiNostrilHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiFeet3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiFeetHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Flattened oval body -->
|
||||
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
|
||||
|
||||
<!-- Big circular pop-out eyes -->
|
||||
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
|
||||
<circle cx="130" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<circle cx="70" cy="80" r="22" fill="url(#froggiEyeWhite3D)" />
|
||||
<circle cx="130" cy="80" r="22" fill="url(#froggiEyeWhite3D)" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<circle cx="70" cy="80" r="16" fill="url(#froggiPupil3D)" />
|
||||
<circle cx="130" cy="80" r="16" fill="url(#froggiPupil3D)" />
|
||||
<circle cx="74" cy="76" r="6" fill="white" opacity="0.9" />
|
||||
<circle cx="134" cy="76" r="6" fill="white" opacity="0.9" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 45 120 Q 100 145 155 120" stroke="url(#froggiMouth3D)" stroke-width="5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 50 122 Q 100 142 150 122" stroke="url(#froggiMouthHighlight)" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced nostrils -->
|
||||
<ellipse cx="90" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
|
||||
<ellipse cx="110" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
|
||||
<ellipse cx="90" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
|
||||
<ellipse cx="110" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
|
||||
|
||||
<!-- Enhanced webbed feet -->
|
||||
<ellipse cx="60" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
|
||||
<ellipse cx="140" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
|
||||
<ellipse cx="60" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
|
||||
<ellipse cx="140" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
|
||||
|
||||
<!-- Enhanced webbed toes -->
|
||||
<path d="M 43 160 Q 47 155 52 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 53 160 Q 57 155 62 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 63 160 Q 67 155 72 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 123 160 Q 127 155 132 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 133 160 Q 137 155 142 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 143 160 Q 147 155 152 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft skin texture details -->
|
||||
<ellipse cx="75" cy="135" rx="4" ry="3" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="125" cy="130" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="85" cy="145" rx="3" ry="2" fill="rgba(255,255,255,0.15)" />
|
||||
<ellipse cx="115" cy="140" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.15)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="froggiBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#22c55e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#15803d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiEyeBase3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="froggiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="froggiMouthHighlight" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:rgba(255,255,255,0.1);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="froggiNostril3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiNostrilHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiFeet3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiFeetHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Flattened oval body -->
|
||||
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
|
||||
|
||||
<!-- Big circular pop-out eyes -->
|
||||
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
|
||||
<circle cx="130" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 54 80 Q 70 83 86 80" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 114 80 Q 130 83 146 80" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="125" r="2" fill="#1e293b" />
|
||||
|
||||
<!-- Enhanced nostrils -->
|
||||
<ellipse cx="90" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
|
||||
<ellipse cx="110" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
|
||||
<ellipse cx="90" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
|
||||
<ellipse cx="110" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
|
||||
|
||||
<!-- Enhanced webbed feet -->
|
||||
<ellipse cx="60" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
|
||||
<ellipse cx="140" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
|
||||
<ellipse cx="60" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
|
||||
<ellipse cx="140" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
|
||||
|
||||
<!-- Enhanced webbed toes -->
|
||||
<path d="M 43 160 Q 47 155 52 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 53 160 Q 57 155 62 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 63 160 Q 67 155 72 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 123 160 Q 127 155 132 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 133 160 Q 137 155 142 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 143 160 Q 147 155 152 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft skin texture details -->
|
||||
<ellipse cx="75" cy="135" rx="4" ry="3" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="125" cy="130" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="85" cy="145" rx="3" ry="2" fill="rgba(255,255,255,0.15)" />
|
||||
<ellipse cx="115" cy="140" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.15)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,116 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sunflower stem -->
|
||||
<rect x="96" y="120" width="8" height="55" fill="url(#leafyStem)" rx="4" />
|
||||
<rect x="98" y="125" width="4" height="50" fill="url(#leafyStemHighlight)" rx="2" opacity="0.6" />
|
||||
|
||||
<!-- Stem leaves -->
|
||||
<ellipse cx="85" cy="140" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(-30 85 140)" />
|
||||
<ellipse cx="115" cy="150" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(30 115 150)" />
|
||||
<ellipse cx="87" cy="140" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(-30 87 140)" opacity="0.7" />
|
||||
<ellipse cx="113" cy="150" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(30 113 150)" opacity="0.7" />
|
||||
|
||||
<!-- Sunflower petals - outer ring -->
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(0 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(22.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(45 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(67.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(90 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(112.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(135 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(157.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(180 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(202.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(225 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(247.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(270 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(292.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(315 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(337.5 100 85)" />
|
||||
|
||||
<!-- Sunflower center - outer ring -->
|
||||
<circle cx="100" cy="85" r="30" fill="url(#leafyCenter)" />
|
||||
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="90" cy="82" r="8" fill="white" />
|
||||
<circle cx="110" cy="82" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="90" cy="82" r="5" fill="#1f2937" />
|
||||
<circle cx="110" cy="82" r="5" fill="#1f2937" />
|
||||
<circle cx="92" cy="80" r="2" fill="white" />
|
||||
<circle cx="112" cy="80" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 92 Q 100 100 112 92" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Floating pollen -->
|
||||
<circle cx="55" cy="60" r="2" fill="url(#leafyPollen)" opacity="0.8" />
|
||||
<circle cx="145" cy="65" r="1.5" fill="url(#leafyPollen)" opacity="0.6" />
|
||||
<circle cx="50" cy="110" r="1" fill="url(#leafyPollen)" opacity="0.7" />
|
||||
<circle cx="150" cy="105" r="2" fill="url(#leafyPollen)" opacity="0.5" />
|
||||
<circle cx="75" cy="45" r="1.5" fill="url(#leafyPollen)" opacity="0.9" />
|
||||
<circle cx="125" cy="50" r="1" fill="url(#leafyPollen)" opacity="0.8" />
|
||||
|
||||
<!-- Leavy pot -->
|
||||
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#leafyPot)" />
|
||||
<rect x="75" y="160" width="50" height="5" fill="url(#leafyPotRim)" rx="2" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="leafyStem" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemLeaf" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemLeafHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#4ade80" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
|
||||
<stop offset="100%" stop-color="#eab308" />
|
||||
<!-- <stop offset="70%" stop-color="#eab308" /> -->
|
||||
<stop offset="30%" stop-color="#fde047" />
|
||||
<stop offset="0%" stop-color="#ffce09" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a16207" />
|
||||
<stop offset="50%" stop-color="#92400e" />
|
||||
<stop offset="100%" stop-color="#78350f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyCenterInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#d97706" />
|
||||
<stop offset="100%" stop-color="#a16207" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafySeed" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#451a03" />
|
||||
<stop offset="100%" stop-color="#292524" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyRoot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a16207" />
|
||||
<stop offset="100%" stop-color="#78350f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fde047" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPotRim" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,113 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sunflower stem -->
|
||||
<rect x="96" y="120" width="8" height="55" fill="url(#leafyStem)" rx="4" />
|
||||
<rect x="98" y="125" width="4" height="50" fill="url(#leafyStemHighlight)" rx="2" opacity="0.6" />
|
||||
|
||||
<!-- Stem leaves -->
|
||||
<ellipse cx="85" cy="140" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(-30 85 140)" />
|
||||
<ellipse cx="115" cy="150" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(30 115 150)" />
|
||||
<ellipse cx="87" cy="140" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(-30 87 140)" opacity="0.7" />
|
||||
<ellipse cx="113" cy="150" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(30 113 150)" opacity="0.7" />
|
||||
|
||||
<!-- Sunflower petals - outer ring -->
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(0 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(22.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(45 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(67.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(90 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(112.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(135 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(157.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(180 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(202.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(225 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(247.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(270 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(292.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(315 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(337.5 100 85)" />
|
||||
|
||||
<!-- Sunflower center - outer ring -->
|
||||
<circle cx="100" cy="85" r="30" fill="url(#leafyCenter)" />
|
||||
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 82 82 Q 90 85 98 82" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 82 Q 110 85 118 82" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="92" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Little arms - small leaves -->
|
||||
<ellipse cx="60" cy="85" rx="12" ry="6" fill="url(#leafyArm)" transform="rotate(-20 60 85)" />
|
||||
<ellipse cx="140" cy="85" rx="12" ry="6" fill="url(#leafyArm)" transform="rotate(20 140 85)" />
|
||||
|
||||
<!-- Base/roots -->
|
||||
<ellipse cx="95" cy="170" rx="8" ry="6" fill="url(#leafyRoot)" />
|
||||
<ellipse cx="105" cy="170" rx="8" ry="6" fill="url(#leafyRoot)" />
|
||||
|
||||
<!-- Floating pollen with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 2,3; 0,0" dur="4s" repeatCount="indefinite" />
|
||||
<circle cx="55" cy="60" r="2" fill="url(#leafyPollen)" opacity="0.5" />
|
||||
<circle cx="145" cy="65" r="1.5" fill="url(#leafyPollen)" opacity="0.4" />
|
||||
<circle cx="50" cy="110" r="1" fill="url(#leafyPollen)" opacity="0.4" />
|
||||
<circle cx="150" cy="105" r="2" fill="url(#leafyPollen)" opacity="0.3" />
|
||||
<circle cx="75" cy="45" r="1.5" fill="url(#leafyPollen)" opacity="0.6" />
|
||||
<circle cx="125" cy="50" r="1" fill="url(#leafyPollen)" opacity="0.5" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="leafyStem" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemLeaf" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemLeafHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#4ade80" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="30%" stop-color="#fde047" />
|
||||
<stop offset="100%" stop-color="#eab308" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a16207" />
|
||||
<stop offset="50%" stop-color="#92400e" />
|
||||
<stop offset="100%" stop-color="#78350f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyCenterInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#d97706" />
|
||||
<stop offset="100%" stop-color="#a16207" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafySeed" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#451a03" />
|
||||
<stop offset="100%" stop-color="#292524" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyRoot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a16207" />
|
||||
<stop offset="100%" stop-color="#78350f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fde047" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,72 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Mushroom stem -->
|
||||
<ellipse cx="100" cy="140" rx="25" ry="40" fill="url(#mushieStem)" />
|
||||
<ellipse cx="100" cy="135" rx="20" ry="35" fill="url(#mushieStemHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Mushroom cap -->
|
||||
<path d="M 50 110 Q 50 70 100 60 Q 150 70 150 110 Z" fill="url(#mushieCap)" />
|
||||
<path d="M 55 108 Q 55 75 100 65 Q 145 75 145 108 Z" fill="url(#mushieCapHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Cap spots -->
|
||||
<circle cx="80" cy="85" r="8" fill="white" opacity="0.8" />
|
||||
<circle cx="120" cy="80" r="10" fill="white" opacity="0.8" />
|
||||
<circle cx="100" cy="95" r="6" fill="white" opacity="0.7" />
|
||||
<circle cx="130" cy="100" r="5" fill="white" opacity="0.6" />
|
||||
<circle cx="70" cy="100" r="5" fill="white" opacity="0.6" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="88" cy="130" r="8" fill="white" />
|
||||
<circle cx="112" cy="130" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="88" cy="130" r="5" fill="#1f2937" />
|
||||
<circle cx="112" cy="130" r="5" fill="#1f2937" />
|
||||
<circle cx="90" cy="128" r="2" fill="white" />
|
||||
<circle cx="114" cy="128" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 145 Q 100 153 112 145" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<ellipse cx="70" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(-20 70 140)" />
|
||||
<ellipse cx="130" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(20 130 140)" />
|
||||
|
||||
<!-- Floating spores -->
|
||||
<circle cx="55" cy="120" r="2" fill="url(#mushieSpore)" opacity="0.8" />
|
||||
<circle cx="145" cy="115" r="1.5" fill="url(#mushieSpore)" opacity="0.6" />
|
||||
<circle cx="50" cy="90" r="1" fill="url(#mushieSpore)" opacity="0.7" />
|
||||
<circle cx="150" cy="95" r="2" fill="url(#mushieSpore)" opacity="0.5" />
|
||||
<circle cx="65" cy="70" r="1.5" fill="url(#mushieSpore)" opacity="0.6" />
|
||||
<circle cx="135" cy="65" r="1" fill="url(#mushieSpore)" opacity="0.8" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="mushieStem" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="30%" stop-color="#fde68a" />
|
||||
<stop offset="70%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieStemHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieCap" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#f87171" />
|
||||
<stop offset="30%" stop-color="#ef4444" />
|
||||
<stop offset="70%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#b91c1c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieCapHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fca5a5" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fde68a" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieSpore" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#c084fc" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,74 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Mushroom stem -->
|
||||
<ellipse cx="100" cy="140" rx="25" ry="40" fill="url(#mushieStem)" />
|
||||
<ellipse cx="100" cy="135" rx="20" ry="35" fill="url(#mushieStemHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Mushroom cap -->
|
||||
<path d="M 50 110 Q 50 70 100 60 Q 150 70 150 110 Z" fill="url(#mushieCap)" />
|
||||
<path d="M 55 108 Q 55 75 100 65 Q 145 75 145 108 Z" fill="url(#mushieCapHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Cap spots -->
|
||||
<circle cx="80" cy="85" r="8" fill="white" opacity="0.8" />
|
||||
<circle cx="120" cy="80" r="10" fill="white" opacity="0.8" />
|
||||
<circle cx="100" cy="95" r="6" fill="white" opacity="0.7" />
|
||||
<circle cx="130" cy="100" r="5" fill="white" opacity="0.6" />
|
||||
<circle cx="70" cy="100" r="5" fill="white" opacity="0.6" />
|
||||
|
||||
<!-- Sleeping eyes on stem -->
|
||||
<path d="M 80 130 Q 88 133 96 130" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 104 130 Q 112 133 120 130" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="145" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<ellipse cx="70" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(-20 70 140)" />
|
||||
<ellipse cx="130" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(20 130 140)" />
|
||||
|
||||
<!-- Floating spores with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 1,2; 0,0" dur="5s" repeatCount="indefinite" />
|
||||
<circle cx="55" cy="120" r="2" fill="url(#mushieSpore)" opacity="0.5" />
|
||||
<circle cx="145" cy="115" r="1.5" fill="url(#mushieSpore)" opacity="0.4" />
|
||||
<circle cx="50" cy="90" r="1" fill="url(#mushieSpore)" opacity="0.4" />
|
||||
<circle cx="150" cy="95" r="2" fill="url(#mushieSpore)" opacity="0.3" />
|
||||
<circle cx="65" cy="70" r="1.5" fill="url(#mushieSpore)" opacity="0.4" />
|
||||
<circle cx="135" cy="65" r="1" fill="url(#mushieSpore)" opacity="0.5" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="mushieStem" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="30%" stop-color="#fde68a" />
|
||||
<stop offset="70%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieStemHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieCap" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#f87171" />
|
||||
<stop offset="30%" stop-color="#ef4444" />
|
||||
<stop offset="70%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#b91c1c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieCapHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fca5a5" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fde68a" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieSpore" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#c084fc" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="owliBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#78716c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEar3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEarInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliBeak3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliBeakHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliWing3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliWingHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Round body -->
|
||||
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" />
|
||||
|
||||
<!-- Triangle ears -->
|
||||
<path d="M 60 70 L 70 48 L 82 70 Z" fill="url(#owliEar3D)" />
|
||||
<path d="M 118 70 L 130 48 L 140 70 Z" fill="url(#owliEar3D)" />
|
||||
<path d="M 65 65 L 70 52 L 77 65 Z" fill="url(#owliEarInner)" />
|
||||
<path d="M 123 65 L 130 52 L 135 65 Z" fill="url(#owliEarInner)" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="80" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
|
||||
<circle cx="120" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="80" cy="100" r="14" fill="url(#owliPupil3D)" />
|
||||
<circle cx="120" cy="100" r="14" fill="url(#owliPupil3D)" />
|
||||
<circle cx="84" cy="96" r="5" fill="white" opacity="0.9" />
|
||||
<circle cx="124" cy="96" r="5" fill="white" opacity="0.9" />
|
||||
|
||||
<!-- Enhanced beak -->
|
||||
<path d="M 100 112 L 94 122 L 100 128 L 106 122 Z" fill="url(#owliBeak3D)" />
|
||||
<path d="M 100 114 L 96 120 L 100 124 L 104 120 Z" fill="url(#owliBeakHighlight)" />
|
||||
|
||||
<!-- Wing details -->
|
||||
<ellipse cx="48" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(-20 48 110)" />
|
||||
<ellipse cx="152" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(20 152 110)" />
|
||||
<ellipse cx="50" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(-20 50 108)" />
|
||||
<ellipse cx="150" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(20 150 108)" />
|
||||
|
||||
<!-- Soft feather texture details -->
|
||||
<circle cx="70" cy="130" r="3" fill="rgba(255,255,255,0.2)" />
|
||||
<circle cx="130" cy="125" r="2.5" fill="rgba(255,255,255,0.2)" />
|
||||
<circle cx="85" cy="140" r="2" fill="rgba(255,255,255,0.15)" />
|
||||
<circle cx="115" cy="135" r="2.5" fill="rgba(255,255,255,0.15)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="owliBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#78716c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEar3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEarInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliBeak3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliBeakHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliWing3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliWingHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Round body -->
|
||||
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" />
|
||||
|
||||
<!-- Triangle ears -->
|
||||
<path d="M 60 70 L 70 48 L 82 70 Z" fill="url(#owliEar3D)" />
|
||||
<path d="M 118 70 L 130 48 L 140 70 Z" fill="url(#owliEar3D)" />
|
||||
<path d="M 65 65 L 70 52 L 77 65 Z" fill="url(#owliEarInner)" />
|
||||
<path d="M 123 65 L 130 52 L 135 65 Z" fill="url(#owliEarInner)" />
|
||||
|
||||
<!-- Large expressive eyes -->
|
||||
<circle cx="80" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
|
||||
<circle cx="120" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 58 100 Q 80 103 102 100" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 98 100 Q 120 103 142 100" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced beak -->
|
||||
<path d="M 100 112 L 94 122 L 100 128 L 106 122 Z" fill="url(#owliBeak3D)" />
|
||||
<path d="M 100 114 L 96 120 L 100 124 L 104 120 Z" fill="url(#owliBeakHighlight)" />
|
||||
|
||||
<!-- Wing details -->
|
||||
<ellipse cx="48" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(-20 48 110)" />
|
||||
<ellipse cx="152" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(20 152 110)" />
|
||||
<ellipse cx="50" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(-20 50 108)" />
|
||||
<ellipse cx="150" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(20 150 108)" />
|
||||
|
||||
<!-- Soft feather texture details -->
|
||||
<circle cx="70" cy="130" r="3" fill="rgba(255,255,255,0.2)" />
|
||||
<circle cx="130" cy="125" r="2.5" fill="rgba(255,255,255,0.2)" />
|
||||
<circle cx="85" cy="140" r="2" fill="rgba(255,255,255,0.15)" />
|
||||
<circle cx="115" cy="135" r="2.5" fill="rgba(255,255,255,0.15)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="pandiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiNose3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="pandiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="pandiArm3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiLeg3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main body - perfect circle -->
|
||||
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
|
||||
|
||||
<!-- Head - perfect circle -->
|
||||
<circle cx="100" cy="85" r="45" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
|
||||
|
||||
<!-- Black ear patches -->
|
||||
<circle cx="70" cy="45" r="18" fill="#1f2937" />
|
||||
<circle cx="130" cy="45" r="18" fill="#1f2937" />
|
||||
|
||||
<!-- Inner ears -->
|
||||
<circle cx="70" cy="45" r="12" fill="#374151" />
|
||||
<circle cx="130" cy="45" r="12" fill="#374151" />
|
||||
|
||||
<!-- Eyes (black patches + white base) -->
|
||||
<circle cx="85" cy="82" r="20" fill="#1f2937" />
|
||||
<circle cx="115" cy="82" r="20" fill="#1f2937" />
|
||||
<circle cx="85" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
|
||||
<circle cx="115" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="85" cy="82" r="8" fill="url(#pandiPupil3D)" />
|
||||
<circle cx="115" cy="82" r="8" fill="url(#pandiPupil3D)" />
|
||||
<circle cx="88" cy="79" r="3" fill="white" />
|
||||
<circle cx="118" cy="79" r="3" fill="white" />
|
||||
|
||||
<!-- Nose -->
|
||||
<path d="M 100 95 L 95 105 L 105 105 Z" fill="url(#pandiNose3D)" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 90 110 Q 100 118 110 110" stroke="url(#pandiMouth3D)" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Arms -->
|
||||
<circle cx="45" cy="120" r="15" fill="url(#pandiArm3D)" />
|
||||
<circle cx="155" cy="120" r="15" fill="url(#pandiArm3D)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<circle cx="80" cy="165" r="18" fill="url(#pandiLeg3D)" />
|
||||
<circle cx="120" cy="165" r="18" fill="url(#pandiLeg3D)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="pandiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiNose3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="pandiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="pandiArm3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiLeg3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main body - perfect circle -->
|
||||
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
|
||||
|
||||
<!-- Head - perfect circle -->
|
||||
<circle cx="100" cy="85" r="45" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
|
||||
|
||||
<!-- Black ear patches -->
|
||||
<circle cx="70" cy="45" r="18" fill="#1f2937" />
|
||||
<circle cx="130" cy="45" r="18" fill="#1f2937" />
|
||||
|
||||
<!-- Inner ears -->
|
||||
<circle cx="70" cy="45" r="12" fill="#374151" />
|
||||
<circle cx="130" cy="45" r="12" fill="#374151" />
|
||||
|
||||
<!-- Eyes -->
|
||||
<circle cx="85" cy="82" r="20" fill="#1f2937" />
|
||||
<circle cx="115" cy="82" r="20" fill="#1f2937" />
|
||||
<circle cx="85" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
|
||||
<circle cx="115" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
|
||||
<path d="M 73 85 Q 85 88 97 85" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 103 85 Q 115 88 127 85" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Nose -->
|
||||
<path d="M 100 95 L 95 105 L 105 105 Z" fill="url(#pandiNose3D)" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="110" r="2" fill="#1e293b" />
|
||||
|
||||
<!-- Arms -->
|
||||
<circle cx="55" cy="120" r="15" fill="url(#pandiArm3D)" />
|
||||
<circle cx="145" cy="120" r="15" fill="url(#pandiArm3D)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<circle cx="80" cy="165" r="18" fill="url(#pandiLeg3D)" />
|
||||
<circle cx="120" cy="165" r="18" fill="url(#pandiLeg3D)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,100 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Bolinha 1 - sentido horário -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="50" cy="80" r="4" fill="url(#rockyPebble)" opacity="0.8" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="5s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 2 - sentido anti-horário -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="150" cy="85" r="3" fill="url(#rockyPebble)" opacity="0.6" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="6s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 3 - sentido horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="45" cy="140" r="2.5" fill="url(#rockyPebble)" opacity="0.7" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="8s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 4 - sentido anti-horário (mais rápido) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="155" cy="135" r="3.5" fill="url(#rockyPebble)" opacity="0.5" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="4s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Rocky's body -->
|
||||
<path d="M 100 50 L 130 70 L 140 110 L 130 150 L 100 165 L 70 150 L 60 110 L 70 70 Z" fill="url(#rockyBody)" />
|
||||
<path d="M 100 55 L 125 72 L 135 108 L 125 145 L 100 158 L 75 145 L 65 108 L 75 72 Z" fill="url(#rockyInner)" opacity="0.8" />
|
||||
|
||||
<!-- Texture -->
|
||||
<path d="M 75 80 L 125 85" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 70 110 L 130 115" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 80 140 L 120 135" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="85" cy="95" r="12" fill="white" />
|
||||
<circle cx="115" cy="95" r="12" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="85" cy="95" r="8" fill="#1f2937" />
|
||||
<circle cx="115" cy="95" r="8" fill="#1f2937" />
|
||||
<circle cx="88" cy="92" r="4" fill="white" />
|
||||
<circle cx="118" cy="92" r="4" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 115 Q 100 123 112 115" stroke="#1f2937" stroke-width="4" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Arms -->
|
||||
<ellipse cx="55" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(-15 55 110)" />
|
||||
<ellipse cx="145" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(15 145 110)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<ellipse cx="85" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
|
||||
<ellipse cx="115" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="rockyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a8a29e" />
|
||||
<stop offset="30%" stop-color="#78716c" />
|
||||
<stop offset="70%" stop-color="#57534e" />
|
||||
<stop offset="100%" stop-color="#44403c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#d6d3d1" />
|
||||
<stop offset="100%" stop-color="#a8a29e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#78716c" />
|
||||
<stop offset="100%" stop-color="#44403c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#57534e" />
|
||||
<stop offset="100%" stop-color="#292524" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyPebble" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#a8a29e" />
|
||||
<stop offset="100%" stop-color="#57534e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,104 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sombra -->
|
||||
<ellipse cx="105" cy="185" rx="50" ry="8" fill="rgba(0,0,0,0.2)" />
|
||||
|
||||
<!-- Bolinha 1 - sentido horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="50" cy="80" r="4" fill="url(#rockyPebble)" opacity="0.5" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="10s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 2 - sentido anti-horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="150" cy="85" r="3" fill="url(#rockyPebble)" opacity="0.4" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="12s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 3 - sentido horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="45" cy="140" r="2.5" fill="url(#rockyPebble)" opacity="0.4" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="16s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 4 - sentido anti-horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="155" cy="135" r="3.5" fill="url(#rockyPebble)" opacity="0.3" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="8s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Corpo do Rocky -->
|
||||
<path d="M 100 50 L 130 70 L 140 110 L 130 150 L 100 165 L 70 150 L 60 110 L 70 70 Z"
|
||||
fill="url(#rockyBody)" />
|
||||
<path d="M 100 55 L 125 72 L 135 108 L 125 145 L 100 158 L 75 145 L 65 108 L 75 72 Z"
|
||||
fill="url(#rockyInner)" opacity="0.8" />
|
||||
|
||||
<!-- Textura -->
|
||||
<path d="M 75 80 L 125 85" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 70 110 L 130 115" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 80 140 L 120 135" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 73 95 Q 85 98 97 95" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 103 95 Q 115 98 127 95" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Braços -->
|
||||
<ellipse cx="55" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(-15 55 110)" />
|
||||
<ellipse cx="145" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(15 145 110)" />
|
||||
|
||||
<!-- Pernas -->
|
||||
<ellipse cx="85" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
|
||||
<ellipse cx="115" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="rockyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a8a29e" />
|
||||
<stop offset="30%" stop-color="#78716c" />
|
||||
<stop offset="70%" stop-color="#57534e" />
|
||||
<stop offset="100%" stop-color="#44403c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#d6d3d1" />
|
||||
<stop offset="100%" stop-color="#a8a29e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#78716c" />
|
||||
<stop offset="100%" stop-color="#44403c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#57534e" />
|
||||
<stop offset="100%" stop-color="#292524" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyPebble" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#a8a29e" />
|
||||
<stop offset="100%" stop-color="#57534e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,94 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Rose stem -->
|
||||
<rect x="98" y="120" width="4" height="50" fill="url(#roseyStem)" rx="2" />
|
||||
|
||||
<!-- Thorns -->
|
||||
<path d="M 98 130 L 94 128" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M 102 145 L 106 143" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
|
||||
|
||||
<!-- Leaves -->
|
||||
<ellipse cx="85" cy="145" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(-30 85 140)" />
|
||||
<ellipse cx="110" cy="150" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(30 115 150)" />
|
||||
|
||||
<!-- Rose petals - layered -->
|
||||
<circle cx="100" cy="90" r="35" fill="url(#roseyPetal1)" />
|
||||
<path d="M 100 60 Q 120 70 125 90 Q 120 110 100 120 Q 80 110 75 90 Q 80 70 100 60" fill="url(#roseyPetal2)" />
|
||||
<path d="M 100 65 Q 115 73 118 90 Q 115 107 100 115 Q 85 107 82 90 Q 85 73 100 65" fill="url(#roseyPetal3)" />
|
||||
<circle cx="100" cy="90" r="20" fill="url(#roseyCenter)" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="90" cy="85" r="8" fill="white" />
|
||||
<circle cx="110" cy="85" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="90" cy="85" r="5" fill="#1f2937" />
|
||||
<circle cx="110" cy="85" r="5" fill="#1f2937" />
|
||||
<circle cx="92" cy="83" r="2" fill="white" />
|
||||
<circle cx="112" cy="83" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 92 100 Q 100 106 108 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Rosy cheeks -->
|
||||
<circle cx="75" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
|
||||
<circle cx="125" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
|
||||
|
||||
<!-- Floating petals -->
|
||||
<ellipse cx="55" cy="70" rx="8" ry="5" fill="url(#roseyFloatingPetal)" opacity="0.8" transform="rotate(45 55 70)" />
|
||||
<ellipse cx="145" cy="75" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.6" transform="rotate(-30 145 75)" />
|
||||
<ellipse cx="50" cy="120" rx="6" ry="3.5" fill="url(#roseyFloatingPetal)" opacity="0.7" transform="rotate(60 50 120)" />
|
||||
<ellipse cx="150" cy="115" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.5" transform="rotate(-45 150 115)" />
|
||||
|
||||
<!-- Rosey pot -->
|
||||
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#leafyPot)" />
|
||||
<rect x="75" y="160" width="50" height="5" fill="url(#leafyPotRim)" rx="2" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="roseyStem" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyLeaf" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal1" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="30%" stop-color="#f9a8d4" />
|
||||
<stop offset="70%" stop-color="#f472b6" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal2" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fbcfe8" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal3" cx="0.5" cy="0.4">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f9a8d4" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyCenter" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f9a8d4" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f9a8d4" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyFloatingPetal" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fbcfe8" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPotRim" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,88 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Rose stem -->
|
||||
<rect x="98" y="120" width="4" height="50" fill="url(#roseyStem)" rx="2" />
|
||||
|
||||
<!-- Thorns -->
|
||||
<path d="M 98 130 L 94 128" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M 102 145 L 106 143" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
|
||||
|
||||
<!-- Leaves -->
|
||||
<ellipse cx="85" cy="140" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(-30 85 140)" />
|
||||
<ellipse cx="115" cy="150" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(30 115 150)" />
|
||||
|
||||
<!-- Rose petals - layered -->
|
||||
<circle cx="100" cy="90" r="35" fill="url(#roseyPetal1)" />
|
||||
<path d="M 100 60 Q 120 70 125 90 Q 120 110 100 120 Q 80 110 75 90 Q 80 70 100 60" fill="url(#roseyPetal2)" />
|
||||
<path d="M 100 65 Q 115 73 118 90 Q 115 107 100 115 Q 85 107 82 90 Q 85 73 100 65" fill="url(#roseyPetal3)" />
|
||||
<circle cx="100" cy="90" r="20" fill="url(#roseyCenter)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 82 85 Q 90 88 98 85" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 85 Q 110 88 118 85" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="100" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Rosy cheeks -->
|
||||
<circle cx="75" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
|
||||
<circle cx="125" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
|
||||
|
||||
<!-- Little arms from center -->
|
||||
<ellipse cx="70" cy="90" rx="8" ry="12" fill="url(#roseyArm)" transform="rotate(-30 70 90)" />
|
||||
<ellipse cx="130" cy="90" rx="8" ry="12" fill="url(#roseyArm)" transform="rotate(30 130 90)" />
|
||||
|
||||
<!-- Floating petals with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 2,3; 0,0" dur="6s" repeatCount="indefinite" />
|
||||
<ellipse cx="55" cy="70" rx="8" ry="5" fill="url(#roseyFloatingPetal)" opacity="0.5" transform="rotate(45 55 70)" />
|
||||
<ellipse cx="145" cy="75" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.4" transform="rotate(-30 145 75)" />
|
||||
<ellipse cx="50" cy="120" rx="6" ry="3.5" fill="url(#roseyFloatingPetal)" opacity="0.4" transform="rotate(60 50 120)" />
|
||||
<ellipse cx="150" cy="115" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.3" transform="rotate(-45 150 115)" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="roseyStem" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyLeaf" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal1" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="30%" stop-color="#f9a8d4" />
|
||||
<stop offset="70%" stop-color="#f472b6" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal2" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fbcfe8" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal3" cx="0.5" cy="0.4">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f9a8d4" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyCenter" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f9a8d4" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f9a8d4" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyFloatingPetal" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fbcfe8" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Star gradients -->
|
||||
<radialGradient id="starriBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4c1d95;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#3730a3;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#1e1b4b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriEye" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="starriSmile" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="starriDust1" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriDust2" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriDust3" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="starriConstellation" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main star body - larger 5-pointed star shape -->
|
||||
<path d="M 100 25 L 115 75 L 165 75 L 125 110 L 140 160 L 100 130 L 60 160 L 75 110 L 35 75 L 85 75 Z" fill="url(#starriBody)" />
|
||||
<path d="M 100 35 L 112 70 L 150 70 L 120 95 L 132 135 L 100 115 L 68 135 L 80 95 L 50 70 L 88 70 Z" fill="url(#starriInner)" opacity="0.8" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="88" cy="95" r="10" fill="url(#starriEye)" />
|
||||
<circle cx="112" cy="95" r="10" fill="url(#starriEye)" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="88" cy="95" r="6" fill="#1e1b4b" />
|
||||
<circle cx="112" cy="95" r="6" fill="#1e1b4b" />
|
||||
<circle cx="90" cy="93" r="3" fill="white" />
|
||||
<circle cx="114" cy="93" r="3" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 115 Q 100 125 112 115" stroke="url(#starriSmile)" stroke-width="4" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Floating stardust -->
|
||||
<circle cx="55" cy="60" r="2" fill="url(#starriDust1)" opacity="0.9" />
|
||||
<circle cx="145" cy="65" r="1.5" fill="url(#starriDust2)" opacity="0.8" />
|
||||
<circle cx="60" cy="140" r="2.5" fill="url(#starriDust3)" opacity="0.7" />
|
||||
<circle cx="140" cy="135" r="2" fill="url(#starriDust1)" opacity="0.6" />
|
||||
<circle cx="40" cy="100" r="1.5" fill="url(#starriDust2)" opacity="0.8" />
|
||||
<circle cx="160" cy="105" r="2" fill="url(#starriDust3)" opacity="0.9" />
|
||||
|
||||
<!-- Constellation lines -->
|
||||
<path d="M 55 60 L 70 45 L 130 50 L 145 65" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.5" />
|
||||
<path d="M 40 100 L 60 140 L 140 135 L 160 105" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Star gradients -->
|
||||
<radialGradient id="starriBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4c1d95;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#3730a3;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#1e1b4b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriEye" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="starriSmile" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="starriDust1" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriDust2" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriDust3" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="starriConstellation" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main star body - larger 5-pointed star shape -->
|
||||
<path d="M 100 25 L 115 75 L 165 75 L 125 110 L 140 160 L 100 130 L 60 160 L 75 110 L 35 75 L 85 75 Z"
|
||||
fill="url(#starriBody)" />
|
||||
<path d="M 100 35 L 112 70 L 150 70 L 120 95 L 132 135 L 100 115 L 68 135 L 80 95 L 50 70 L 88 70 Z"
|
||||
fill="url(#starriInner)" opacity="0.8" />
|
||||
|
||||
<!-- Twinkling eyes -->
|
||||
<circle cx="88" cy="95" r="10" fill="url(#starriEye)" />
|
||||
<circle cx="112" cy="95" r="10" fill="url(#starriEye)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 78 95 Q 88 98 98 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 95 Q 112 98 122 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#f59e0b" />
|
||||
|
||||
<!-- Floating stardust with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 1,2; 0,0" dur="4s" repeatCount="indefinite" />
|
||||
<circle cx="55" cy="60" r="2" fill="url(#starriDust1)" opacity="0.6" />
|
||||
<circle cx="145" cy="65" r="1.5" fill="url(#starriDust2)" opacity="0.5" />
|
||||
<circle cx="60" cy="140" r="2.5" fill="url(#starriDust3)" opacity="0.4" />
|
||||
<circle cx="140" cy="135" r="2" fill="url(#starriDust1)" opacity="0.4" />
|
||||
<circle cx="40" cy="100" r="1.5" fill="url(#starriDust2)" opacity="0.5" />
|
||||
<circle cx="160" cy="105" r="2" fill="url(#starriDust3)" opacity="0.6" />
|
||||
</g>
|
||||
|
||||
<!-- Constellation lines -->
|
||||
<path d="M 55 60 L 70 45 L 130 50 L 145 65" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.3" />
|
||||
<path d="M 40 100 L 60 140 L 140 135 L 160 105" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.3" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Adult Blobbi Module
|
||||
*
|
||||
* Self-contained module for adult stage Blobbi visuals and customization.
|
||||
* This module includes:
|
||||
* - Adult SVG assets (awake and sleeping variants for each form)
|
||||
* - SVG resolution and loading utilities
|
||||
* - Color and customization utilities
|
||||
* - Type definitions
|
||||
*
|
||||
* This module is designed to be portable and can be moved to other projects.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AdultForm,
|
||||
AdultVariant,
|
||||
AdultSvgCustomization,
|
||||
AdultSvgResolverOptions,
|
||||
} from './types/adult.types';
|
||||
|
||||
export {
|
||||
ADULT_FORMS,
|
||||
extractAdultCustomization,
|
||||
isValidAdultForm,
|
||||
getDefaultAdultForm,
|
||||
resolveAdultForm,
|
||||
deriveAdultFormFromSeed,
|
||||
} from './types/adult.types';
|
||||
|
||||
// SVG Resolution
|
||||
export {
|
||||
getAdultBaseSvg,
|
||||
getAdultSleepingSvg,
|
||||
getAdultSvgByVariant,
|
||||
resolveAdultSvg,
|
||||
resolveAdultSvgWithForm,
|
||||
getAvailableAdultForms,
|
||||
preloadAdultSvgs,
|
||||
} from './lib/adult-svg-resolver';
|
||||
|
||||
// SVG Customization
|
||||
export {
|
||||
customizeAdultSvg,
|
||||
customizeAdultSvgFromBlobbi,
|
||||
} from './lib/adult-svg-customizer';
|
||||
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* Adult Blobbi SVG Customizer
|
||||
*
|
||||
* Handles applying colors and customizations to adult SVG content.
|
||||
* Each adult form has different gradient IDs that need color mapping.
|
||||
*
|
||||
* IMPORTANT: Gradients must be preserved for 3D shading effects.
|
||||
* We replace gradient colors, not the gradient structure.
|
||||
*
|
||||
* Uses shared utilities from blobbi/ui/lib/svg for common operations.
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { lightenColor, darkenColor, uniquifySvgIds, ensureSvgFillsContainer } from '@/blobbi/ui/lib/svg';
|
||||
import type { AdultForm, AdultSvgCustomization } from '../types/adult.types';
|
||||
|
||||
// ─── Gradient Builders ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a 3-stop radial gradient (highlight -> mid -> base)
|
||||
*/
|
||||
function buildRadialGradient3Stop(
|
||||
id: string,
|
||||
baseColor: string,
|
||||
cx = '0.3',
|
||||
cy = '0.2'
|
||||
): string {
|
||||
const highlight = lightenColor(baseColor, 40);
|
||||
const mid = lightenColor(baseColor, 20);
|
||||
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
|
||||
<stop offset="0%" style="stop-color:${highlight};stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:${mid};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 2-stop radial gradient (lighter -> base)
|
||||
*/
|
||||
function buildRadialGradient2Stop(
|
||||
id: string,
|
||||
baseColor: string,
|
||||
cx = '0.3',
|
||||
cy = '0.3'
|
||||
): string {
|
||||
const highlight = lightenColor(baseColor, 25);
|
||||
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
|
||||
<stop offset="0%" style="stop-color:${highlight};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 4-stop radial gradient (used by droppi, rocky, starri bodies)
|
||||
*/
|
||||
function buildRadialGradient4Stop(
|
||||
id: string,
|
||||
baseColor: string,
|
||||
cx = '0.3',
|
||||
cy = '0.2'
|
||||
): string {
|
||||
const veryLight = lightenColor(baseColor, 50);
|
||||
const light = lightenColor(baseColor, 25);
|
||||
const dark = darkenColor(baseColor, 15);
|
||||
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
|
||||
<stop offset="0%" style="stop-color:${veryLight};stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:${light};stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${dark};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a petal gradient (outer -> inner style, like rosey/leafy)
|
||||
*/
|
||||
function buildPetalGradient(
|
||||
id: string,
|
||||
baseColor: string,
|
||||
cx = '0.3',
|
||||
cy = '0.2'
|
||||
): string {
|
||||
const veryLight = lightenColor(baseColor, 50);
|
||||
const light = lightenColor(baseColor, 30);
|
||||
const mid = lightenColor(baseColor, 15);
|
||||
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
|
||||
<stop offset="0%" style="stop-color:${veryLight};stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:${light};stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:${mid};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build pupil gradient
|
||||
*/
|
||||
function buildPupilGradient(id: string, eyeColor: string): string {
|
||||
const highlight = lightenColor(eyeColor, 20);
|
||||
return `<radialGradient id="${id}" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${highlight};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${eyeColor};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
// ─── Generic Gradient Replacer ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace a specific gradient in the SVG by ID
|
||||
*/
|
||||
function replaceGradient(
|
||||
svgText: string,
|
||||
gradientId: string,
|
||||
newGradient: string
|
||||
): string {
|
||||
// Match both radialGradient and linearGradient
|
||||
const pattern = new RegExp(
|
||||
`<(radial|linear)Gradient[^>]*id=["']${gradientId}["'][^>]*>[\\s\\S]*?<\\/(radial|linear)Gradient>`,
|
||||
'i'
|
||||
);
|
||||
|
||||
const match = svgText.match(pattern);
|
||||
if (match) {
|
||||
return svgText.replace(match[0], newGradient);
|
||||
}
|
||||
return svgText;
|
||||
}
|
||||
|
||||
// ─── Form-Specific Customizers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Catti: Body, ears, and tail should use Blobbi color
|
||||
* Gradients: cattiBody3D, cattiEar3D, cattiEarInner, cattiTail3D, cattiTailHighlight
|
||||
*/
|
||||
function customizeCatti(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body gradient (3-stop)
|
||||
svg = replaceGradient(svg, 'cattiBody3D', buildRadialGradient3Stop('cattiBody3D', baseColor));
|
||||
|
||||
// Ear gradients (2-stop)
|
||||
svg = replaceGradient(svg, 'cattiEar3D', buildRadialGradient2Stop('cattiEar3D', baseColor));
|
||||
|
||||
// Ear inner uses lighter color
|
||||
const earInnerColor = lightenColor(baseColor, 20);
|
||||
svg = replaceGradient(svg, 'cattiEarInner', buildRadialGradient2Stop('cattiEarInner', earInnerColor, '0.4', '0.3'));
|
||||
|
||||
// Tail gradients
|
||||
const tailHighlight = lightenColor(baseColor, 40);
|
||||
svg = replaceGradient(svg, 'cattiTail3D', `<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 15)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
svg = replaceGradient(svg, 'cattiTailHighlight', buildRadialGradient2Stop('cattiTailHighlight', tailHighlight, '0.4', '0.3'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Droppi: Body, arms, legs, and droplets should use Blobbi color
|
||||
* Gradients: droppiBody, droppiInner, droppiArm, droppiLeg, droppiDroplet
|
||||
*/
|
||||
function customizeDroppi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop)
|
||||
svg = replaceGradient(svg, 'droppiBody', buildRadialGradient4Stop('droppiBody', baseColor));
|
||||
|
||||
// Inner reflection (lighter, 2-stop)
|
||||
const innerColor = lightenColor(baseColor, 45);
|
||||
svg = replaceGradient(svg, 'droppiInner', buildRadialGradient2Stop('droppiInner', innerColor, '0.4', '0.3'));
|
||||
|
||||
// Arms (2-stop)
|
||||
svg = replaceGradient(svg, 'droppiArm', buildRadialGradient2Stop('droppiArm', lightenColor(baseColor, 15)));
|
||||
|
||||
// Legs (2-stop, slightly darker)
|
||||
svg = replaceGradient(svg, 'droppiLeg', buildRadialGradient2Stop('droppiLeg', darkenColor(baseColor, 5), '0.3', '0.2'));
|
||||
|
||||
// Droplets
|
||||
svg = replaceGradient(svg, 'droppiDroplet', buildRadialGradient2Stop('droppiDroplet', lightenColor(baseColor, 30), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flammi: Body, inner, core, arms, legs, and embers should use Blobbi color
|
||||
* Gradients: flammiBody, flammiInner, flammiCore, flammiArm, flammiLeg, flammiEmber
|
||||
*/
|
||||
function customizeFlammi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop gradient with warm progression)
|
||||
svg = replaceGradient(svg, 'flammiBody', buildRadialGradient4Stop('flammiBody', baseColor));
|
||||
|
||||
// Inner (3-stop, lighter)
|
||||
const innerColor = lightenColor(baseColor, 25);
|
||||
svg = replaceGradient(svg, 'flammiInner', `<radialGradient id="flammiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(innerColor, 30)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${innerColor};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 10)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
// Core (hottest/brightest part, very light)
|
||||
const coreColor = lightenColor(baseColor, 50);
|
||||
svg = replaceGradient(svg, 'flammiCore', buildRadialGradient2Stop('flammiCore', coreColor, '0.5', '0.4'));
|
||||
|
||||
// Arms
|
||||
svg = replaceGradient(svg, 'flammiArm', buildRadialGradient2Stop('flammiArm', lightenColor(baseColor, 10)));
|
||||
|
||||
// Legs
|
||||
svg = replaceGradient(svg, 'flammiLeg', buildRadialGradient2Stop('flammiLeg', baseColor, '0.3', '0.2'));
|
||||
|
||||
// Embers
|
||||
svg = replaceGradient(svg, 'flammiEmber', buildRadialGradient2Stop('flammiEmber', lightenColor(baseColor, 35), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Froggi: Body, eye base, feet should use Blobbi color
|
||||
* Gradients: froggiBody3D, froggiEyeBase3D, froggiFeet3D, froggiFeetHighlight, froggiToe3D
|
||||
*/
|
||||
function customizeFroggi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (3-stop)
|
||||
svg = replaceGradient(svg, 'froggiBody3D', buildRadialGradient3Stop('froggiBody3D', baseColor));
|
||||
|
||||
// Eye base (matches body color, 2-stop)
|
||||
svg = replaceGradient(svg, 'froggiEyeBase3D', buildRadialGradient2Stop('froggiEyeBase3D', lightenColor(baseColor, 15)));
|
||||
|
||||
// Feet (2-stop, lighter than body)
|
||||
const feetColor = lightenColor(baseColor, 20);
|
||||
svg = replaceGradient(svg, 'froggiFeet3D', buildRadialGradient2Stop('froggiFeet3D', feetColor, '0.3', '0.2'));
|
||||
|
||||
// Feet highlight (even lighter)
|
||||
svg = replaceGradient(svg, 'froggiFeetHighlight', buildRadialGradient2Stop('froggiFeetHighlight', lightenColor(feetColor, 20), '0.4', '0.3'));
|
||||
|
||||
// Toes (linear gradient, darker)
|
||||
svg = replaceGradient(svg, 'froggiToe3D', `<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:${darkenColor(baseColor, 10)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 25)};stop-opacity:1" />
|
||||
</linearGradient>`);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leafy: Petals should use Blobbi color (center/face keeps brown)
|
||||
* Gradients: leafyPetal (petals only - the yellow parts)
|
||||
*/
|
||||
function customizeLeafy(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Petal gradient (the sunflower petals)
|
||||
svg = replaceGradient(svg, 'leafyPetal', `<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
|
||||
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 15)};stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:${lightenColor(baseColor, 25)};stop-opacity:1" />
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mushie: Cap should use Blobbi color (stem keeps original)
|
||||
* Gradients: mushieCap, mushieCapHighlight
|
||||
*/
|
||||
function customizeMushie(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Cap (4-stop)
|
||||
svg = replaceGradient(svg, 'mushieCap', buildRadialGradient4Stop('mushieCap', baseColor));
|
||||
|
||||
// Cap highlight (lighter)
|
||||
svg = replaceGradient(svg, 'mushieCapHighlight', buildRadialGradient2Stop('mushieCapHighlight', lightenColor(baseColor, 25), '0.4', '0.3'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rocky: Body, inner, arms, legs, and pebbles should use Blobbi color
|
||||
* Gradients: rockyBody, rockyInner, rockyArm, rockyLeg, rockyPebble
|
||||
*/
|
||||
function customizeRocky(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop)
|
||||
svg = replaceGradient(svg, 'rockyBody', buildRadialGradient4Stop('rockyBody', baseColor));
|
||||
|
||||
// Inner (2-stop, lighter)
|
||||
svg = replaceGradient(svg, 'rockyInner', buildRadialGradient2Stop('rockyInner', lightenColor(baseColor, 35), '0.4', '0.3'));
|
||||
|
||||
// Arms (2-stop)
|
||||
svg = replaceGradient(svg, 'rockyArm', buildRadialGradient2Stop('rockyArm', baseColor));
|
||||
|
||||
// Legs (2-stop, slightly darker)
|
||||
svg = replaceGradient(svg, 'rockyLeg', buildRadialGradient2Stop('rockyLeg', darkenColor(baseColor, 10), '0.3', '0.2'));
|
||||
|
||||
// Pebbles
|
||||
svg = replaceGradient(svg, 'rockyPebble', buildRadialGradient2Stop('rockyPebble', lightenColor(baseColor, 15), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rosey: Petals, center, and floating petals should use Blobbi color
|
||||
* Gradients: roseyPetal1, roseyPetal2, roseyPetal3, roseyCenter, roseyFloatingPetal
|
||||
*/
|
||||
function customizeRosey(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Petal layers (outer to inner, using petal gradient style)
|
||||
svg = replaceGradient(svg, 'roseyPetal1', buildPetalGradient('roseyPetal1', baseColor));
|
||||
|
||||
// Petal2 (slightly lighter)
|
||||
svg = replaceGradient(svg, 'roseyPetal2', buildRadialGradient2Stop('roseyPetal2', lightenColor(baseColor, 15), '0.4', '0.3'));
|
||||
|
||||
// Petal3 (lightest inner petals)
|
||||
svg = replaceGradient(svg, 'roseyPetal3', buildRadialGradient2Stop('roseyPetal3', lightenColor(baseColor, 30), '0.5', '0.4'));
|
||||
|
||||
// Center (where face is, slightly darker)
|
||||
svg = replaceGradient(svg, 'roseyCenter', buildRadialGradient2Stop('roseyCenter', lightenColor(baseColor, 10)));
|
||||
|
||||
// Floating petals
|
||||
svg = replaceGradient(svg, 'roseyFloatingPetal', buildRadialGradient2Stop('roseyFloatingPetal', lightenColor(baseColor, 20), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starri: Inner star should use Blobbi color (outer stays dark/cosmic)
|
||||
* Gradients: starriInner (the inner golden star - this should be the Blobbi color)
|
||||
*/
|
||||
function customizeStarri(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Inner star (3-stop gradient to maintain depth)
|
||||
svg = replaceGradient(svg, 'starriInner', `<radialGradient id="starriInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breezy: Body, inner, veins, arms, legs, and floating leaves should use Blobbi color
|
||||
* Gradients: breezyBody, breezyInner, breezyVein, breezyArm, breezyLeg, breezyFloating
|
||||
*/
|
||||
function customizeBreezy(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop leaf gradient)
|
||||
svg = replaceGradient(svg, 'breezyBody', buildRadialGradient4Stop('breezyBody', baseColor));
|
||||
|
||||
// Inner highlight (lighter, 2-stop)
|
||||
svg = replaceGradient(svg, 'breezyInner', buildRadialGradient2Stop('breezyInner', lightenColor(baseColor, 40), '0.4', '0.3'));
|
||||
|
||||
// Veins (linear gradient, darker)
|
||||
svg = replaceGradient(svg, 'breezyVein', `<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:${darkenColor(baseColor, 20)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${darkenColor(baseColor, 10)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 20)};stop-opacity:1" />
|
||||
</linearGradient>`);
|
||||
|
||||
// Arms (2-stop)
|
||||
svg = replaceGradient(svg, 'breezyArm', buildRadialGradient2Stop('breezyArm', lightenColor(baseColor, 15)));
|
||||
|
||||
// Legs (2-stop)
|
||||
svg = replaceGradient(svg, 'breezyLeg', buildRadialGradient2Stop('breezyLeg', baseColor, '0.3', '0.2'));
|
||||
|
||||
// Floating leaves
|
||||
svg = replaceGradient(svg, 'breezyFloating', buildRadialGradient2Stop('breezyFloating', lightenColor(baseColor, 25), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloomi: Petals, center, and pollen should use Blobbi color
|
||||
* Note: Bloomi has 6 different colored petals - we'll make them all use variations of the base color
|
||||
* Gradients: bloomiPetal1-6, bloomiCenter, bloomiPollen
|
||||
*/
|
||||
function customizeBloomi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// All 6 petals use variations of the Blobbi color
|
||||
// Create a gradient effect across petals by varying lightness
|
||||
svg = replaceGradient(svg, 'bloomiPetal1', buildRadialGradient2Stop('bloomiPetal1', lightenColor(baseColor, 30)));
|
||||
svg = replaceGradient(svg, 'bloomiPetal2', buildRadialGradient2Stop('bloomiPetal2', lightenColor(baseColor, 20)));
|
||||
svg = replaceGradient(svg, 'bloomiPetal3', buildRadialGradient2Stop('bloomiPetal3', lightenColor(baseColor, 10)));
|
||||
svg = replaceGradient(svg, 'bloomiPetal4', buildRadialGradient2Stop('bloomiPetal4', baseColor));
|
||||
svg = replaceGradient(svg, 'bloomiPetal5', buildRadialGradient2Stop('bloomiPetal5', darkenColor(baseColor, 10)));
|
||||
svg = replaceGradient(svg, 'bloomiPetal6', buildRadialGradient2Stop('bloomiPetal6', darkenColor(baseColor, 5)));
|
||||
|
||||
// Center (3-stop, lighter than petals - this is where the face is)
|
||||
svg = replaceGradient(svg, 'bloomiCenter', `<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 45)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 25)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
// Pollen (floating particles)
|
||||
svg = replaceGradient(svg, 'bloomiPollen', buildRadialGradient2Stop('bloomiPollen', lightenColor(baseColor, 40), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cacti: Body and arms should use Blobbi color (pot keeps original red)
|
||||
* Gradients: cactiBody, cactiArm
|
||||
*/
|
||||
function customizeCacti(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop)
|
||||
svg = replaceGradient(svg, 'cactiBody', buildRadialGradient4Stop('cactiBody', baseColor));
|
||||
|
||||
// Arms (2-stop)
|
||||
svg = replaceGradient(svg, 'cactiArm', buildRadialGradient2Stop('cactiArm', lightenColor(baseColor, 10)));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudi: Body, highlights, and raindrops should use Blobbi color
|
||||
* Gradients: cloudiBody, cloudiHighlight, cloudiRain
|
||||
*/
|
||||
function customizeCloudi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (3-stop, cloud-like progression from light to slightly darker)
|
||||
svg = replaceGradient(svg, 'cloudiBody', `<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 45)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 30)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
// Highlights (very light, semi-transparent feel)
|
||||
svg = replaceGradient(svg, 'cloudiHighlight', `<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 50)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 40)};stop-opacity:0.5" />
|
||||
</radialGradient>`);
|
||||
|
||||
// Raindrops (use darker version of the color)
|
||||
svg = replaceGradient(svg, 'cloudiRain', buildRadialGradient2Stop('cloudiRain', darkenColor(baseColor, 10), '0.5', '0.3'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crysti: Body and inner should use Blobbi color (facets keep their colorful nature)
|
||||
* Gradients: crystiBody, crystiInner
|
||||
*/
|
||||
function customizeCrysti(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop crystal gradient)
|
||||
svg = replaceGradient(svg, 'crystiBody', buildRadialGradient4Stop('crystiBody', baseColor));
|
||||
|
||||
// Inner highlight (semi-transparent white feel preserved but tinted)
|
||||
svg = replaceGradient(svg, 'crystiInner', `<radialGradient id="crystiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 50)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:0.3" />
|
||||
</radialGradient>`);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owli: Body, ears, and wings should use Blobbi color (beak keeps yellow/orange)
|
||||
* Gradients: owliBody3D, owliEar3D, owliWing3D, owliWingHighlight
|
||||
*/
|
||||
function customizeOwli(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (3-stop)
|
||||
svg = replaceGradient(svg, 'owliBody3D', buildRadialGradient3Stop('owliBody3D', baseColor));
|
||||
|
||||
// Ears (2-stop, slightly darker)
|
||||
svg = replaceGradient(svg, 'owliEar3D', buildRadialGradient2Stop('owliEar3D', darkenColor(baseColor, 10), '0.3', '0.2'));
|
||||
|
||||
// Wings (2-stop)
|
||||
svg = replaceGradient(svg, 'owliWing3D', buildRadialGradient2Stop('owliWing3D', darkenColor(baseColor, 15), '0.3', '0.2'));
|
||||
|
||||
// Wing highlights (lighter)
|
||||
svg = replaceGradient(svg, 'owliWingHighlight', buildRadialGradient2Stop('owliWingHighlight', lightenColor(baseColor, 10), '0.4', '0.3'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// ─── Form Customizer Map ──────────────────────────────────────────────────────
|
||||
|
||||
type FormCustomizer = (svgText: string, baseColor: string) => string;
|
||||
|
||||
const FORM_CUSTOMIZERS: Partial<Record<AdultForm, FormCustomizer>> = {
|
||||
bloomi: customizeBloomi,
|
||||
breezy: customizeBreezy,
|
||||
cacti: customizeCacti,
|
||||
catti: customizeCatti,
|
||||
cloudi: customizeCloudi,
|
||||
crysti: customizeCrysti,
|
||||
droppi: customizeDroppi,
|
||||
flammi: customizeFlammi,
|
||||
froggi: customizeFroggi,
|
||||
leafy: customizeLeafy,
|
||||
mushie: customizeMushie,
|
||||
owli: customizeOwli,
|
||||
rocky: customizeRocky,
|
||||
rosey: customizeRosey,
|
||||
starri: customizeStarri,
|
||||
// pandi keeps original colors - it's a panda with black/white coloring by design
|
||||
};
|
||||
|
||||
// ─── Main Customization ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply color customizations to adult SVG.
|
||||
*
|
||||
* Each form has specific gradients that need to be replaced
|
||||
* to apply the Blobbi's custom colors while preserving 3D shading.
|
||||
*
|
||||
* @param svgText - The SVG content to customize
|
||||
* @param form - The adult form type
|
||||
* @param customization - Color customization options
|
||||
* @param isSleeping - Whether the Blobbi is sleeping (affects eye rendering)
|
||||
* @param instanceId - Optional unique ID to prevent gradient ID collisions when multiple Blobbis are rendered
|
||||
*/
|
||||
export function customizeAdultSvg(
|
||||
svgText: string,
|
||||
form: AdultForm,
|
||||
customization: AdultSvgCustomization,
|
||||
isSleeping: boolean = false,
|
||||
instanceId?: string
|
||||
): string {
|
||||
let modifiedSvg = svgText;
|
||||
|
||||
// Ensure SVG fills its container
|
||||
modifiedSvg = ensureSvgFillsContainer(modifiedSvg);
|
||||
|
||||
// Skip color customization if no colors provided
|
||||
if (!customization.baseColor && !customization.secondaryColor && !customization.eyeColor) {
|
||||
// Still uniquify IDs if instanceId provided (even without color changes)
|
||||
if (instanceId) {
|
||||
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
|
||||
}
|
||||
return modifiedSvg;
|
||||
}
|
||||
|
||||
// Apply form-specific body/part customization
|
||||
if (customization.baseColor) {
|
||||
const customizer = FORM_CUSTOMIZERS[form];
|
||||
if (customizer) {
|
||||
modifiedSvg = customizer(modifiedSvg, customization.baseColor);
|
||||
} else {
|
||||
// Fallback for forms without specific customizer: try generic body gradient
|
||||
modifiedSvg = applyGenericBodyGradient(modifiedSvg, form, customization.baseColor);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply eye color customization (skip for sleeping SVGs - eyes are closed)
|
||||
if (customization.eyeColor && !isSleeping) {
|
||||
modifiedSvg = applyPupilGradient(modifiedSvg, form, customization.eyeColor);
|
||||
}
|
||||
|
||||
// Make all IDs unique to prevent collisions when multiple Blobbis are rendered
|
||||
if (instanceId) {
|
||||
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
|
||||
}
|
||||
|
||||
return modifiedSvg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Apply generic body gradient for forms without specific customizer
|
||||
*/
|
||||
function applyGenericBodyGradient(
|
||||
svgText: string,
|
||||
form: AdultForm,
|
||||
baseColor: string
|
||||
): string {
|
||||
let modified = svgText;
|
||||
|
||||
// Try common patterns: {form}Body3D, {form}Body
|
||||
const bodyPatterns = [
|
||||
new RegExp(`<radialGradient[^>]*id=["'](${form}Body3D)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
|
||||
new RegExp(`<radialGradient[^>]*id=["'](${form}Body)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
|
||||
];
|
||||
|
||||
for (const pattern of bodyPatterns) {
|
||||
const match = modified.match(pattern);
|
||||
if (match) {
|
||||
const gradientId = match[1];
|
||||
const newGradient = buildRadialGradient3Stop(gradientId, baseColor);
|
||||
modified = modified.replace(match[0], newGradient);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pupil gradient customization
|
||||
*/
|
||||
function applyPupilGradient(
|
||||
svgText: string,
|
||||
form: AdultForm,
|
||||
eyeColor: string
|
||||
): string {
|
||||
let modified = svgText;
|
||||
|
||||
// Try common patterns: {form}Pupil3D, {form}Pupil
|
||||
const pupilPatterns = [
|
||||
new RegExp(`<radialGradient[^>]*id=["'](${form}Pupil3D)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
|
||||
new RegExp(`<radialGradient[^>]*id=["'](${form}Pupil)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
|
||||
];
|
||||
|
||||
for (const pattern of pupilPatterns) {
|
||||
const match = modified.match(pattern);
|
||||
if (match) {
|
||||
const gradientId = match[1];
|
||||
const newGradient = buildPupilGradient(gradientId, eyeColor);
|
||||
modified = modified.replace(match[0], newGradient);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
// ─── Convenience Functions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convenience function to customize adult SVG from a Blobbi instance.
|
||||
*
|
||||
* Uses the Blobbi's ID to uniquify SVG IDs, preventing gradient collisions
|
||||
* when multiple Blobbis are rendered on the same page.
|
||||
*/
|
||||
export function customizeAdultSvgFromBlobbi(
|
||||
svgText: string,
|
||||
form: AdultForm,
|
||||
blobbi: Blobbi,
|
||||
isSleeping: boolean = false
|
||||
): string {
|
||||
const customization: AdultSvgCustomization = {
|
||||
baseColor: blobbi.baseColor,
|
||||
secondaryColor: blobbi.secondaryColor,
|
||||
eyeColor: blobbi.eyeColor,
|
||||
};
|
||||
|
||||
// Pass blobbi.id to uniquify gradient IDs and prevent collisions
|
||||
return customizeAdultSvg(svgText, form, customization, isSleeping, blobbi.id);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Adult Blobbi SVG Resolver
|
||||
*
|
||||
* Handles loading and resolving adult stage SVG assets.
|
||||
* Each adult form has its own folder with base and sleeping variants.
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import {
|
||||
type AdultForm,
|
||||
type AdultSvgResolverOptions,
|
||||
ADULT_FORMS,
|
||||
resolveAdultForm,
|
||||
getDefaultAdultForm,
|
||||
} from '../types/adult.types';
|
||||
import { ADULT_SVG_MAP } from './adult-svg-data';
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get adult base SVG content for a specific form
|
||||
*/
|
||||
export function getAdultBaseSvg(form: AdultForm): string {
|
||||
return ADULT_SVG_MAP[form]?.base ?? getFallbackAdultSvg(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get adult sleeping SVG content for a specific form
|
||||
*/
|
||||
export function getAdultSleepingSvg(form: AdultForm): string {
|
||||
return ADULT_SVG_MAP[form]?.sleeping ?? getFallbackAdultSvg(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get adult SVG by form and variant
|
||||
*/
|
||||
export function getAdultSvgByVariant(
|
||||
form: AdultForm,
|
||||
variant: 'base' | 'sleeping'
|
||||
): string {
|
||||
return variant === 'sleeping'
|
||||
? getAdultSleepingSvg(form)
|
||||
: getAdultBaseSvg(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve adult Blobbi SVG content.
|
||||
*
|
||||
* Determines the correct form from blobbi data (evolutionForm or seed-derived),
|
||||
* then returns the appropriate SVG based on sleeping state.
|
||||
*/
|
||||
export function resolveAdultSvg(
|
||||
blobbi: Blobbi,
|
||||
options: AdultSvgResolverOptions = {}
|
||||
): string {
|
||||
const { isSleeping = false } = options;
|
||||
|
||||
if (blobbi.lifeStage !== 'adult') {
|
||||
console.warn('resolveAdultSvg called with non-adult Blobbi');
|
||||
return getFallbackAdultSvg(getDefaultAdultForm());
|
||||
}
|
||||
|
||||
const form = resolveAdultForm(blobbi);
|
||||
return isSleeping ? getAdultSleepingSvg(form) : getAdultBaseSvg(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve adult form from Blobbi and return both form and SVG
|
||||
*/
|
||||
export function resolveAdultSvgWithForm(
|
||||
blobbi: Blobbi,
|
||||
options: AdultSvgResolverOptions = {}
|
||||
): { form: AdultForm; svg: string } {
|
||||
const { isSleeping = false } = options;
|
||||
const form = resolveAdultForm(blobbi);
|
||||
const svg = isSleeping ? getAdultSleepingSvg(form) : getAdultBaseSvg(form);
|
||||
return { form, svg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available adult forms
|
||||
*/
|
||||
export function getAvailableAdultForms(): readonly AdultForm[] {
|
||||
return ADULT_FORMS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all adult SVGs for quick switching
|
||||
*/
|
||||
export function preloadAdultSvgs(): void {
|
||||
// All SVGs are inlined constants — this function exists for API consistency
|
||||
// This function exists for API consistency
|
||||
for (const form of ADULT_FORMS) {
|
||||
getAdultBaseSvg(form);
|
||||
getAdultSleepingSvg(form);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fallback ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get fallback adult SVG content.
|
||||
* Used when the expected asset is not found.
|
||||
*/
|
||||
function getFallbackAdultSvg(form: AdultForm): string {
|
||||
// Simple placeholder SVG that indicates the form name
|
||||
return `
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="fallbackAdultGradient" cx="0.3" cy="0.25">
|
||||
<stop offset="0%" style="stop-color:#a78bfa"/>
|
||||
<stop offset="60%" style="stop-color:#8b5cf6"/>
|
||||
<stop offset="100%" style="stop-color:#7c3aed"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<!-- Body -->
|
||||
<ellipse cx="100" cy="110" rx="50" ry="60" fill="url(#fallbackAdultGradient)" />
|
||||
<!-- Eyes -->
|
||||
<ellipse cx="82" cy="95" rx="10" ry="12" fill="#fff" />
|
||||
<ellipse cx="118" cy="95" rx="10" ry="12" fill="#fff" />
|
||||
<circle cx="82" cy="96" r="7" fill="#374151" />
|
||||
<circle cx="118" cy="96" r="7" fill="#374151" />
|
||||
<circle cx="84" cy="94" r="2.5" fill="white" />
|
||||
<circle cx="120" cy="94" r="2.5" fill="white" />
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 120 Q 100 130 112 120" stroke="#374151" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<!-- Form label (dev only) -->
|
||||
<text x="100" y="180" text-anchor="middle" font-size="12" fill="#666">${form}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||