mirror of
https://github.com/remvze/moodist.git
synced 2026-03-06 12:03:14 +08:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320d05d414 | ||
|
|
fdd23899f1 | ||
|
|
24c0d58eee | ||
|
|
cb37b08d37 | ||
|
|
716fbca0f6 | ||
|
|
cf77d33bd8 | ||
|
|
876d575854 | ||
|
|
41845ffe5e | ||
|
|
48a85b2601 | ||
|
|
5865fc867d | ||
|
|
b27f24d374 | ||
|
|
5c9a2aa23a | ||
|
|
12d3255d57 | ||
|
|
c12ef12b79 | ||
|
|
ba3cd5ca5b | ||
|
|
a3b794d974 | ||
|
|
3ef4a076a2 | ||
|
|
1f2b6b952c | ||
|
|
2bbdc7e09e | ||
|
|
47a63a774e | ||
|
|
edd53d8102 | ||
|
|
302a71cdc6 | ||
|
|
b73fd0b16e | ||
|
|
5b3972b347 | ||
|
|
bee391acfe | ||
|
|
1fd02f927c | ||
|
|
d56f8be448 | ||
|
|
eee755378a | ||
|
|
4b015016e7 | ||
|
|
251f30930c | ||
|
|
a29e2c20e4 | ||
|
|
1cf9a85e13 | ||
|
|
69eb8832da | ||
|
|
c1ece582f4 | ||
|
|
b32d8b2803 | ||
|
|
1768ba1548 | ||
|
|
a80289db57 | ||
|
|
9208663050 | ||
|
|
d2edeb48be | ||
|
|
27f25785e1 | ||
|
|
f526f97908 | ||
|
|
e399673462 | ||
|
|
3d83a1427f | ||
|
|
ddf929f4c0 | ||
|
|
5ffb06be03 | ||
|
|
d6ed3fd251 | ||
|
|
0052b917a8 | ||
|
|
9e38a8fd7d | ||
|
|
60cb453847 | ||
|
|
fc4f52146e | ||
|
|
1a1359c989 | ||
|
|
a6c7ac41ad | ||
|
|
3e11fb6123 | ||
|
|
ee0a28b296 | ||
|
|
d356d77aa9 | ||
|
|
9cc0ccd325 | ||
|
|
cad85c7667 | ||
|
|
def9a57e0c | ||
|
|
74f6b5851d | ||
|
|
f4c66e3092 | ||
|
|
28abc16b9c | ||
|
|
787a9b60b5 | ||
|
|
73a5c21be9 | ||
|
|
cfd2744e92 | ||
|
|
4c0f417469 | ||
|
|
9d1d8f8035 | ||
|
|
8a79ccf018 | ||
|
|
a3c384d105 | ||
|
|
96ca376885 | ||
|
|
18987cc339 | ||
|
|
919831538f | ||
|
|
edd15f4b9a | ||
|
|
09c0a6ce93 | ||
|
|
2bfb9b181c | ||
|
|
c272914416 | ||
|
|
d73b2bc1ff | ||
|
|
c5657d0642 | ||
|
|
c35409ce0a | ||
|
|
7658842324 | ||
|
|
78222be011 | ||
|
|
2c8135db43 | ||
|
|
fddf75cdca | ||
|
|
0f50e6ae8b | ||
|
|
4ae0504937 | ||
|
|
af075b32e6 | ||
|
|
82d8240b97 | ||
|
|
096251ec0a | ||
|
|
2a86a88ed6 |
@@ -41,6 +41,7 @@
|
||||
"sort-destructure-keys/sort-destructure-keys": "warn",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"jsx-a11y/media-has-caption": "off",
|
||||
"jsx-a11y/no-noninteractive-tabindex": "off",
|
||||
"react/jsx-sort-props": [
|
||||
"warn",
|
||||
{
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
"rules": {
|
||||
"import-notation": "string",
|
||||
"selector-class-pattern": null
|
||||
"selector-class-pattern": null,
|
||||
"no-descending-specificity": null
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
|
||||
2980
package-lock.json
generated
2980
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -8,6 +8,7 @@
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"test": "vitest",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"lint:style": "stylelint ./**/*.{css,astro,html}",
|
||||
@@ -23,14 +24,15 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^3.0.3",
|
||||
"@astrojs/react": "3.6.0",
|
||||
"@floating-ui/react": "0.26.0",
|
||||
"@formkit/auto-animate": "0.8.2",
|
||||
"@radix-ui/react-dropdown-menu": "2.0.6",
|
||||
"@radix-ui/react-tooltip": "1.0.7",
|
||||
"@types/howler": "2.2.10",
|
||||
"@types/react": "^18.2.25",
|
||||
"@types/react-dom": "^18.2.10",
|
||||
"astro": "4.0.3",
|
||||
"astro": "4.10.3",
|
||||
"deepmerge": "4.3.1",
|
||||
"focus-trap-react": "10.2.3",
|
||||
"framer-motion": "10.16.4",
|
||||
@@ -40,6 +42,7 @@
|
||||
"react-hotkeys-hook": "3.2.1",
|
||||
"react-icons": "4.11.0",
|
||||
"react-wrap-balancer": "1.1.0",
|
||||
"uuid": "10.0.0",
|
||||
"zustand": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -88,6 +91,7 @@
|
||||
"stylelint-config-html": "1.1.0",
|
||||
"stylelint-config-recess-order": "4.4.0",
|
||||
"stylelint-config-standard": "34.0.0",
|
||||
"stylelint-prettier": "4.0.2"
|
||||
"stylelint-prettier": "4.0.2",
|
||||
"vitest": "1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M100 150C127.614 150 150 127.614 150 100C150 72.3858 127.614 50 100 50C72.3858 50 50 72.3858 50 100C50 127.614 72.3858 150 100 150ZM100 125C113.807 125 125 113.807 125 100C125 86.1929 113.807 75 100 75C86.1929 75 75 86.1929 75 100C75 113.807 86.1929 125 100 125Z" fill="#FAFAFA"/>
|
||||
<path d="M50 50C22.3858 50 2.00172e-06 72.3857 7.94663e-07 100C-4.12393e-07 127.614 22.3858 150 50 150V125C36.1929 125 25 113.807 25 100C25 86.1929 36.1929 75 50 75V50Z" fill="#D4D4D8"/>
|
||||
<path d="M150 50C150 22.3858 127.614 2.41411e-06 100 0C72.3858 -2.41411e-06 50 22.3858 50 50L75 50C75 36.1929 86.1929 25 100 25C113.807 25 125 36.1929 125 50H150Z" fill="#D4D4D8"/>
|
||||
<path d="M150 150C177.614 150 200 127.614 200 100C200 72.3858 177.614 50 150 50V75C163.807 75 175 86.1929 175 100C175 113.807 163.807 125 150 125V150Z" fill="#D4D4D8"/>
|
||||
<path d="M50 150C50 177.614 72.3857 200 100 200C127.614 200 150 177.614 150 150H125C125 163.807 113.807 175 100 175C86.1929 175 75 163.807 75 150H50Z" fill="#D4D4D8"/>
|
||||
<path d="M25 50C25 36.1929 36.1929 25 50 25V1.39091e-06C22.3858 1.83851e-07 2.00172e-06 22.3857 7.94663e-07 50H25Z" fill="#A1A1AA"/>
|
||||
<path d="M150 25C163.807 25 175 36.1929 175 50H200C200 22.3858 177.614 3.24858e-06 150 8.34465e-07L150 25Z" fill="#A1A1AA"/>
|
||||
<path d="M175 150C175 163.807 163.807 175 150 175V200C177.614 200 200 177.614 200 150H175Z" fill="#A1A1AA"/>
|
||||
<path d="M50 175C36.1929 175 25 163.807 25 150H0C0 177.614 22.3857 200 50 200V175Z" fill="#A1A1AA"/>
|
||||
<path d="M100 64.4661C100 58.6219 101.411 53.1072 103.911 48.2441L103.911 48.2429L103.928 48.2107C105.198 45.7464 106.747 43.4498 108.535 41.3629C112.141 37.1521 116.716 33.7949 121.914 31.6361C121.346 30.9518 120.742 30.2877 120.101 29.6465C116.762 26.3072 112.799 23.9724 108.587 22.642C106.178 24.1973 103.912 25.9549 101.813 27.891C101.691 28.0031 101.571 28.1157 101.45 28.229C100.957 28.6929 100.474 29.1667 100 29.6501C99.5262 29.1667 99.0427 28.6929 98.5497 28.229C96.3499 26.1591 93.9616 24.2873 91.4133 22.642C88.8486 20.9862 86.1219 19.5598 83.2621 18.392C77.5239 16.0487 71.2502 14.7462 64.6755 14.7191C64.6057 14.7188 64.5359 14.7186 64.4661 14.7186C61.0306 14.7186 57.6765 15.0669 54.4371 15.73C35.009 19.7068 19.7068 35.009 15.7299 54.4371C21.5092 50.112 27.9257 47.0293 34.6104 45.1892C37.3473 40.959 40.9589 37.3474 45.1892 34.6104C47.9325 32.8355 50.936 31.4284 54.1299 30.4589C57.4006 29.4661 60.871 28.9322 64.4661 28.9322C66.6264 28.9322 68.7416 29.125 70.7956 29.4943C73.3293 29.9498 75.7698 30.6739 78.0864 31.6362C83.2841 33.795 87.8585 37.1521 91.4651 41.3629C93.2606 43.4592 94.8163 45.7672 96.0894 48.2441C96.2311 48.5197 96.3692 48.7973 96.5039 49.0771C98.7445 53.7333 100 58.9531 100 64.4661Z" fill="#FAFAFA"/>
|
||||
<path d="M125.126 74.8737C129.259 70.7413 134.156 67.8394 139.362 66.1682C139.558 66.1053 139.755 66.0441 139.952 65.9846C142.418 65.2401 144.949 64.7693 147.498 64.5724C153.025 64.1451 158.634 65.0059 163.836 67.1547C163.919 66.2697 163.961 65.3729 163.961 64.4661C163.961 59.7437 162.81 55.2903 160.772 51.3713C157.934 50.7602 155.053 50.3994 152.163 50.289C152.034 50.2841 151.905 50.2796 151.775 50.2757C151.099 50.2551 150.422 50.2482 149.745 50.2551C149.752 49.5783 149.745 48.9014 149.724 48.2248C149.632 45.2056 149.267 42.1933 148.629 39.228C147.986 36.2436 147.067 33.3069 145.87 30.4589C143.47 24.7444 139.954 19.3872 135.325 14.7191C135.275 14.6695 135.226 14.6201 135.177 14.5707C132.748 12.1414 130.13 10.0159 127.37 8.1942C110.82 -2.7314 89.1798 -2.7314 72.63 8.19421C79.7748 9.2225 86.4918 11.5799 92.5196 15.0055C97.4462 13.9496 102.554 13.9496 107.48 15.0055C110.675 15.6902 113.794 16.8191 116.738 18.3919C119.753 20.0027 122.584 22.0791 125.126 24.6212C126.654 26.1487 128.013 27.7808 129.204 29.4942C130.674 31.608 131.888 33.8457 132.845 36.1642C134.994 41.366 135.855 46.9746 135.428 52.5022C135.215 55.2542 134.683 57.9862 133.832 60.6379C133.737 60.9329 133.638 61.2269 133.536 61.5199C131.828 66.3968 129.025 70.9754 125.126 74.8737Z" fill="#FAFAFA"/>
|
||||
<path d="M135.534 100C141.378 100 146.893 101.411 151.756 103.911L151.789 103.928C154.254 105.198 156.55 106.747 158.637 108.535C162.848 112.141 166.205 116.716 168.364 121.914C169.048 121.346 169.712 120.742 170.354 120.101C173.693 116.762 176.028 112.799 177.358 108.587C175.713 106.038 173.841 103.65 171.771 101.45C171.307 100.957 170.833 100.474 170.35 100C170.833 99.5263 171.307 99.0427 171.771 98.5497C173.841 96.3499 175.713 93.9616 177.358 91.4133C179.014 88.8486 180.44 86.1218 181.608 83.262C183.951 77.5238 185.254 71.2501 185.281 64.6754C185.281 64.6057 185.281 64.5359 185.281 64.4661C185.281 61.0306 184.933 57.6764 184.27 54.437C180.293 35.009 164.991 19.7069 145.563 15.73C149.888 21.5092 152.971 27.9258 154.811 34.6104C159.041 37.3474 162.653 40.959 165.39 45.1892C167.165 47.9325 168.572 50.936 169.541 54.1299C170.534 57.4006 171.068 60.871 171.068 64.4661C171.068 66.6264 170.875 68.7416 170.506 70.7955C170.05 73.3293 169.326 75.7697 168.364 78.0864C166.205 83.2841 162.848 87.8586 158.637 91.4651C156.541 93.2607 154.233 94.8163 151.756 96.0894C151.48 96.2311 151.203 96.3693 150.923 96.5039C146.267 98.7445 141.047 100 135.534 100Z" fill="#FAFAFA"/>
|
||||
<path d="M125.126 125.126C129.259 129.259 132.161 134.156 133.832 139.362C133.895 139.558 133.956 139.755 134.015 139.952C134.76 142.418 135.231 144.949 135.428 147.498C135.855 153.025 134.994 158.634 132.845 163.836C133.73 163.919 134.627 163.961 135.534 163.961C140.256 163.961 144.71 162.81 148.629 160.772C149.24 157.934 149.601 155.052 149.711 152.163C149.716 152.034 149.72 151.905 149.724 151.775C149.745 151.099 149.752 150.422 149.745 149.745C150.422 149.752 151.099 149.745 151.775 149.724C154.794 149.632 157.807 149.267 160.772 148.629C163.756 147.986 166.693 147.066 169.541 145.87C175.256 143.47 180.613 139.954 185.281 135.325C185.33 135.275 185.38 135.226 185.429 135.177C187.859 132.748 189.984 130.13 191.806 127.37C202.731 110.82 202.731 89.1797 191.806 72.6299C190.777 79.7747 188.42 86.4917 184.995 92.5196C186.05 97.4462 186.05 102.554 184.995 107.48C184.31 110.675 183.181 113.794 181.608 116.738C179.997 119.753 177.921 122.584 175.379 125.126C173.851 126.654 172.219 128.013 170.506 129.204C168.392 130.674 166.154 131.888 163.836 132.845C158.634 134.994 153.025 135.855 147.498 135.428C144.746 135.215 142.014 134.683 139.362 133.832C139.067 133.737 138.773 133.638 138.48 133.536C133.603 131.828 129.025 129.025 125.126 125.126Z" fill="#FAFAFA"/>
|
||||
<path d="M100 135.534C100 141.378 98.5891 146.893 96.0894 151.756L96.0723 151.789C94.8023 154.254 93.2526 156.55 91.4651 158.637C87.8585 162.848 83.284 166.205 78.0864 168.364C78.6536 169.048 79.2578 169.712 79.899 170.354C83.2382 173.693 87.2014 176.028 91.4133 177.358C93.9478 175.722 96.324 173.861 98.514 171.805L98.5497 171.771C99.0427 171.307 99.5262 170.833 100 170.35C100.474 170.833 100.957 171.307 101.45 171.771C103.65 173.841 106.038 175.713 108.587 177.358C111.151 179.014 113.878 180.44 116.738 181.608C122.476 183.951 128.75 185.254 135.325 185.281C135.394 185.281 135.464 185.281 135.534 185.281C138.969 185.281 142.324 184.933 145.563 184.27C164.991 180.293 180.293 164.991 184.27 145.563C178.491 149.888 172.074 152.971 165.39 154.811C162.653 159.041 159.041 162.653 154.811 165.39C152.068 167.164 149.064 168.572 145.87 169.541C142.599 170.534 139.129 171.068 135.534 171.068C133.374 171.068 131.258 170.875 129.204 170.506C126.671 170.05 124.23 169.326 121.914 168.364C116.716 166.205 112.141 162.848 108.535 158.637C106.739 156.541 105.184 154.233 103.911 151.756C103.769 151.48 103.631 151.203 103.496 150.923C101.255 146.267 100 141.047 100 135.534Z" fill="#FAFAFA"/>
|
||||
<path d="M74.8737 125.126C70.7413 129.259 65.8442 132.161 60.6379 133.832C60.4418 133.895 60.2453 133.956 60.0483 134.015C57.582 134.76 55.0507 135.231 52.5022 135.428C46.9745 135.855 41.366 134.994 36.1642 132.845C36.0813 133.73 36.0389 134.627 36.0389 135.534C36.0389 140.256 37.1904 144.71 39.2279 148.629C42.1932 149.267 45.2056 149.632 48.2247 149.724C48.9013 149.745 49.5783 149.752 50.2551 149.745C50.2483 150.422 50.2551 151.099 50.2757 151.775C50.3676 154.794 50.7328 157.807 51.3714 160.772C52.014 163.756 52.9335 166.693 54.1299 169.541C56.5304 175.256 60.0456 180.613 64.6755 185.281C64.7246 185.33 64.7739 185.38 64.8232 185.429C67.2525 187.859 69.8705 189.984 72.63 191.806C89.1798 202.731 110.82 202.731 127.37 191.806C120.225 190.778 113.508 188.42 107.48 184.995C102.554 186.05 97.4462 186.05 92.5196 184.994C89.3248 184.31 86.206 183.181 83.2621 181.608C80.2473 179.997 77.4159 177.921 74.8737 175.379C73.3462 173.851 71.9868 172.219 70.7956 170.506C69.3261 168.392 68.1125 166.154 67.1547 163.836C65.0059 158.634 64.1451 153.025 64.5724 147.498C64.7851 144.746 65.317 142.014 66.1682 139.362C66.2629 139.067 66.3616 138.773 66.4642 138.48C68.1723 133.603 70.9755 129.025 74.8737 125.126Z" fill="#FAFAFA"/>
|
||||
<path d="M41.3629 108.535C43.4592 106.739 45.7671 105.184 48.2441 103.911C48.5197 103.769 48.7973 103.631 49.0771 103.496C53.7333 101.255 58.9531 100 64.4661 100C58.6219 100 53.1072 98.5891 48.2441 96.0894L48.2428 96.0888L48.2107 96.0723C45.7464 94.8023 43.4498 93.2526 41.3629 91.4651C37.1521 87.8586 33.7949 83.2841 31.6361 78.0864C30.9517 78.6536 30.2877 79.2578 29.6465 79.899C26.3072 83.2382 23.9724 87.2015 22.642 91.4134C23.7775 93.1722 25.021 94.8547 26.363 96.4517C26.9654 97.1686 27.5877 97.8682 28.2289 98.5498C28.6928 99.0427 29.1666 99.5263 29.6501 100C29.1666 100.474 28.6928 100.957 28.2289 101.45C26.1591 103.65 24.2873 106.038 22.642 108.587C20.9862 111.151 19.5598 113.878 18.3919 116.738C16.0486 122.476 14.7461 128.75 14.719 135.324C14.7187 135.394 14.7186 135.464 14.7186 135.534C14.7186 138.969 15.0668 142.324 15.7299 145.563C19.7068 164.991 35.009 180.293 54.437 184.27C50.112 178.491 47.0293 172.074 45.1892 165.39C40.959 162.653 37.3474 159.041 34.6104 154.811C32.8354 152.067 31.4283 149.064 30.4588 145.87C29.466 142.599 28.9321 139.129 28.9321 135.534C28.9321 133.374 29.1249 131.258 29.4942 129.204C29.9498 126.671 30.6739 124.23 31.6361 121.914C33.7949 116.716 37.1521 112.141 41.3629 108.535Z" fill="#FAFAFA"/>
|
||||
<path d="M52.5022 64.5724C55.2542 64.7851 57.9862 65.317 60.6379 66.1682C60.9329 66.2629 61.227 66.3616 61.5199 66.4642C66.3968 68.1723 70.9755 70.9754 74.8737 74.8737C70.7413 70.7413 67.8394 65.8442 66.1682 60.6379C66.1053 60.4418 66.0441 60.2453 65.9846 60.0483C65.2401 57.582 64.7694 55.0507 64.5724 52.5022C64.1451 46.9746 65.0059 41.366 67.1547 36.1642C66.2697 36.0813 65.3729 36.039 64.4661 36.039C59.7437 36.039 55.2903 37.1904 51.3714 39.2279C50.7328 42.1933 50.3676 45.2056 50.2757 48.2248C50.2551 48.9014 50.2483 49.5783 50.2551 50.2551C49.5783 50.2482 48.9014 50.2551 48.2248 50.2757C45.2056 50.3676 42.1933 50.7328 39.2279 51.3714C36.2435 52.014 33.3068 52.9335 30.4589 54.1299C24.7444 56.5305 19.3872 60.0457 14.719 64.6755C14.6695 64.7246 14.6201 64.7739 14.5707 64.8232C12.1414 67.2525 10.0159 69.8705 8.19417 72.63C-2.73139 89.1798 -2.73139 110.82 8.19418 127.37C9.22247 120.225 11.5798 113.508 15.0055 107.48C13.9496 102.554 13.9496 97.4463 15.0055 92.5197C15.6902 89.3248 16.8191 86.2061 18.3919 83.2621C20.0027 80.2473 22.0791 77.4159 24.6212 74.8737C26.1487 73.3462 27.7807 71.9868 29.4942 70.7956C31.6079 69.3261 33.8457 68.1125 36.1642 67.1547C41.366 65.0059 46.9745 64.1451 52.5022 64.5724Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 9.7 KiB |
@@ -18,10 +18,6 @@ const paragraphs = [
|
||||
body: 'The beauty of Moodist lies in its simplicity and customization. No complex menus or confusing options – just choose your desired sounds, adjust the volume balance, and hit play. Want to blend the gentle chirping of birds with the soothing sound of rain? No problem! Layer as many sounds as you like to create your personalized soundscape oasis.',
|
||||
title: 'Create Your Soundscape',
|
||||
},
|
||||
// {
|
||||
// body: 'Moodist goes beyond just ambient sounds by offering a suite of productivity tools to help you stay organized and focused. Utilize the built-in pomodoro timer to structure your workday in focused intervals, jot down thoughts and ideas in the simple notepad, and keep track of your tasks with the handy to-do list. These tools seamlessly integrate with the ambient soundscapes, allowing you to create a personalized environment that fosters both focus and relaxation.',
|
||||
// title: 'A Productivity Toolbox',
|
||||
// },
|
||||
{
|
||||
body: "Whether you're looking to unwind after a long day, enhance your focus during work, or lull yourself into a peaceful sleep, Moodist has the perfect soundscape waiting for you. The best part? It's completely free and open-source, so you can enjoy its benefits without any strings attached. Start using Moodist today and discover your new haven of tranquility and focus!",
|
||||
title: 'Sounds for Every Moment',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import { BiSolidHeart } from 'react-icons/bi/index';
|
||||
import { Howler } from 'howler';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import { Container } from '@/components/container';
|
||||
import { StoreConsumer } from '@/components/store-consumer';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { BiPause, BiPlay } from 'react-icons/bi/index';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useSnackbar } from '@/contexts/snackbar';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
@@ -48,14 +48,14 @@ export function PlayButton() {
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<span>
|
||||
<span aria-hidden="true">
|
||||
<BiPause />
|
||||
</span>{' '}
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
<span aria-hidden="true">
|
||||
<BiPlay />
|
||||
</span>{' '}
|
||||
Play
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { Tooltip } from '@/components/tooltip';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { fade, mix, slideX } from '@/lib/motion';
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
background-color: var(--color-neutral-100);
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-50),
|
||||
var(--color-neutral-100)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -31,6 +34,16 @@
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
|
||||
& span {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-foreground),
|
||||
var(--color-foreground-subtle)
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& .desc {
|
||||
|
||||
@@ -9,12 +9,14 @@ export function Donate() {
|
||||
<div className={styles.donate}>
|
||||
<div className={styles.iconContainer}>
|
||||
<div className={styles.tail} />
|
||||
<div className={styles.icon}>
|
||||
<div aria-hidden="true" className={styles.icon}>
|
||||
<FaCoffee />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>Support Me</div>
|
||||
<div className={styles.title}>
|
||||
<span>Support Me</span>
|
||||
</div>
|
||||
<p className={styles.desc}>Help me keep Moodist ad-free.</p>
|
||||
<SpecialButton
|
||||
className={styles.button}
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
background-color: var(--color-neutral-100);
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-50),
|
||||
var(--color-neutral-100)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@ export function Category({
|
||||
title,
|
||||
}: CategoryProps) {
|
||||
return (
|
||||
<div className={styles.category}>
|
||||
<div className={styles.category} id={`category-${id}`}>
|
||||
<div className={styles.iconContainer}>
|
||||
<div className={styles.tail} />
|
||||
<div className={styles.icon}>{icon}</div>
|
||||
<div aria-hidden="true" className={styles.icon}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
@@ -11,24 +11,21 @@ const count = soundCount();
|
||||
<div class="hero">
|
||||
<Container>
|
||||
<div class="wrapper">
|
||||
<div class="pattern"></div>
|
||||
<img
|
||||
alt="Faded Moodist Logo"
|
||||
aria-hidden="true"
|
||||
class="logo"
|
||||
height={45}
|
||||
src="/logo.svg"
|
||||
width={45}
|
||||
/>
|
||||
|
||||
<div class="title">
|
||||
<div class="left"></div>
|
||||
<h2>Moodist</h2>
|
||||
<div class="right"></div>
|
||||
</div>
|
||||
|
||||
<h2 class="title"><span>Moodist</span></h2>
|
||||
<h1 class="desc">Ambient sounds for focus and calm.</h1>
|
||||
|
||||
<p class="sounds">
|
||||
<span class="icon">
|
||||
<span aria-hidden="true" class="icon">
|
||||
<BsSoundwave />
|
||||
</span>
|
||||
<span>{count} Sounds</span>
|
||||
@@ -43,54 +40,55 @@ const count = soundCount();
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: 100px 0 80px;
|
||||
padding: 120px 0 80px;
|
||||
|
||||
& .pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: radial-gradient(
|
||||
var(--color-neutral-500) 5%,
|
||||
transparent 5%
|
||||
);
|
||||
background-position: top center;
|
||||
background-size: 21px 21px;
|
||||
opacity: 0.8;
|
||||
mask-image: linear-gradient(#fff, transparent, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
& .logo {
|
||||
display: block;
|
||||
width: 45px;
|
||||
margin: 0 auto 12px;
|
||||
margin: 0 auto 16px;
|
||||
animation-name: logo;
|
||||
animation-duration: 45s;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
& .title {
|
||||
display: flex;
|
||||
column-gap: 15px;
|
||||
align-items: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-2xlg);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
|
||||
& div {
|
||||
flex-grow: 1;
|
||||
height: 1px;
|
||||
|
||||
&.left {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent,
|
||||
var(--color-neutral-200),
|
||||
var(--color-neutral-300)
|
||||
);
|
||||
}
|
||||
|
||||
&.right {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-neutral-300),
|
||||
var(--color-neutral-200),
|
||||
transparent,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
& h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-2xlg);
|
||||
font-weight: 600;
|
||||
& span {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-foreground),
|
||||
var(--color-foreground-subtle)
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& .desc {
|
||||
margin-top: 5px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
@@ -139,4 +137,14 @@ const count = soundCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
18
src/components/menu/items/breathing-exercise.tsx
Normal file
18
src/components/menu/items/breathing-exercise.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IoMdFlower } from 'react-icons/io/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
interface BreathingExerciseProps {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
export function BreathingExercise({ open }: BreathingExerciseProps) {
|
||||
return (
|
||||
<Item
|
||||
icon={<IoMdFlower />}
|
||||
label="Breathing Exercise"
|
||||
shortcut="Shift + B"
|
||||
onClick={open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,17 @@ import { MdOutlineTimer } from 'react-icons/md/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
export function CountdownTimer() {
|
||||
interface CountdownProps {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
export function Countdown({ open }: CountdownProps) {
|
||||
return (
|
||||
<Item
|
||||
href="https://timesy.app"
|
||||
icon={<MdOutlineTimer />}
|
||||
label="Countdown Timer"
|
||||
shortcut="Shift + C"
|
||||
onClick={open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
export { Shuffle as ShuffleItem } from './shuffle';
|
||||
export { Share as ShareItem } from './share';
|
||||
export { Donate as DonateItem } from './donate';
|
||||
export { Notepad as NotepadItem } from './notepad';
|
||||
export { Source as SourceItem } from './source';
|
||||
export { Pomodoro as PomodoroItem } from './pomodoro';
|
||||
export { Presets as PresetsItem } from './presets';
|
||||
export { Shortcuts as ShortcutsItem } from './shortcuts';
|
||||
export { SleepTimer as SleepTimerItem } from './sleep-timer';
|
||||
export { CountdownTimer as CountdownTimerItem } from './countdown-timer';
|
||||
export { BreathingExercise as BreathingExerciseItem } from './breathing-exercise';
|
||||
export { Pomodoro as PomodoroItem } from './pomodoro';
|
||||
export { Notepad as NotepadItem } from './notepad';
|
||||
export { Todo as TodoItem } from './todo';
|
||||
export { Countdown as CountdownItem } from './countdown';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MdNotes } from 'react-icons/md/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
import { useNoteStore } from '@/store';
|
||||
import { useNoteStore } from '@/stores/note';
|
||||
|
||||
interface NotepadProps {
|
||||
open: () => void;
|
||||
@@ -15,7 +15,7 @@ export function Notepad({ open }: NotepadProps) {
|
||||
<Item
|
||||
active={!!note.length}
|
||||
icon={<MdNotes />}
|
||||
label="Notepad"
|
||||
label="Simple Notepad"
|
||||
shortcut="Shift + N"
|
||||
onClick={open}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MdOutlineAvTimer } from 'react-icons/md/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
import { usePomodoroStore } from '@/store';
|
||||
import { usePomodoroStore } from '@/stores/pomodoro';
|
||||
|
||||
interface PomodoroProps {
|
||||
open: () => void;
|
||||
@@ -15,7 +15,7 @@ export function Pomodoro({ open }: PomodoroProps) {
|
||||
<Item
|
||||
active={running}
|
||||
icon={<MdOutlineAvTimer />}
|
||||
label="Pomodoro"
|
||||
label="Pomodoro Timer"
|
||||
shortcut="Shift + P"
|
||||
onClick={open}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IoShareSocialSharp } from 'react-icons/io5/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
interface ShareProps {
|
||||
open: () => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BiShuffle } from 'react-icons/bi/index';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IoMoonSharp } from 'react-icons/io5/index';
|
||||
|
||||
import { useSleepTimerStore } from '@/stores/sleep-timer';
|
||||
import { Item } from '../item';
|
||||
|
||||
interface SleepTimerProps {
|
||||
@@ -7,11 +8,14 @@ interface SleepTimerProps {
|
||||
}
|
||||
|
||||
export function SleepTimer({ open }: SleepTimerProps) {
|
||||
const active = useSleepTimerStore(state => state.active);
|
||||
|
||||
return (
|
||||
<Item
|
||||
active={active}
|
||||
icon={<IoMoonSharp />}
|
||||
label="Sleep Timer"
|
||||
shortcut="Shift + T"
|
||||
shortcut="Shift + Alt + T"
|
||||
onClick={open}
|
||||
/>
|
||||
);
|
||||
|
||||
18
src/components/menu/items/todo.tsx
Normal file
18
src/components/menu/items/todo.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { MdTaskAlt } from 'react-icons/md/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
interface TodoProps {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
export function Todo({ open }: TodoProps) {
|
||||
return (
|
||||
<Item
|
||||
icon={<MdTaskAlt />}
|
||||
label="Todo Checklist"
|
||||
shortcut="Shift + T"
|
||||
onClick={open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
color: var(--color-foreground);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
@@ -30,12 +31,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
width: 250px;
|
||||
width: 270px;
|
||||
height: max-content;
|
||||
max-height: var(--radix-dropdown-menu-content-available-height);
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -8,22 +8,25 @@ import {
|
||||
ShuffleItem,
|
||||
ShareItem,
|
||||
DonateItem,
|
||||
NotepadItem,
|
||||
SourceItem,
|
||||
PomodoroItem,
|
||||
PresetsItem,
|
||||
ShortcutsItem,
|
||||
SleepTimerItem,
|
||||
CountdownTimerItem,
|
||||
BreathingExerciseItem,
|
||||
PomodoroItem,
|
||||
NotepadItem,
|
||||
TodoItem,
|
||||
CountdownItem,
|
||||
} from './items';
|
||||
import { Divider } from './divider';
|
||||
import { ShareLinkModal } from '@/components/modals/share-link';
|
||||
import { PresetsModal } from '@/components/modals/presets';
|
||||
import { ShortcutsModal } from '@/components/modals/shortcuts';
|
||||
import { SleepTimerModal } from '@/components/modals/sleep-timer';
|
||||
import { Notepad, Pomodoro } from '@/components/toolbox';
|
||||
import { BreathingExerciseModal } from '../modals/breathing';
|
||||
import { Pomodoro, Notepad, Todo, Countdown } from '../toolbox';
|
||||
import { fade, mix, slideY } from '@/lib/motion';
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import styles from './menu.module.css';
|
||||
import { useCloseListener } from '@/hooks/use-close-listener';
|
||||
@@ -36,12 +39,15 @@ export function Menu() {
|
||||
|
||||
const initial = useMemo(
|
||||
() => ({
|
||||
breathing: false,
|
||||
countdown: false,
|
||||
notepad: false,
|
||||
pomodoro: false,
|
||||
presets: false,
|
||||
shareLink: false,
|
||||
shortcuts: false,
|
||||
sleepTimer: false,
|
||||
todo: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -65,12 +71,15 @@ export function Menu() {
|
||||
);
|
||||
|
||||
useHotkeys('shift+m', () => setIsOpen(prev => !prev));
|
||||
useHotkeys('shift+n', () => open('notepad'));
|
||||
useHotkeys('shift+p', () => open('pomodoro'));
|
||||
useHotkeys('shift+alt+p', () => open('presets'));
|
||||
useHotkeys('shift+h', () => open('shortcuts'));
|
||||
useHotkeys('shift+b', () => open('breathing'));
|
||||
useHotkeys('shift+n', () => open('notepad'));
|
||||
useHotkeys('shift+p', () => open('pomodoro'));
|
||||
useHotkeys('shift+t', () => open('todo'));
|
||||
useHotkeys('shift+c', () => open('countdown'));
|
||||
useHotkeys('shift+s', () => open('shareLink'), { enabled: !noSelected });
|
||||
useHotkeys('shift+t', () => open('sleepTimer'));
|
||||
useHotkeys('shift+alt+t', () => open('sleepTimer'));
|
||||
|
||||
useCloseListener(closeAll);
|
||||
|
||||
@@ -109,9 +118,11 @@ export function Menu() {
|
||||
<SleepTimerItem open={() => open('sleepTimer')} />
|
||||
|
||||
<Divider />
|
||||
<NotepadItem open={() => open('notepad')} />
|
||||
<CountdownItem open={() => open('countdown')} />
|
||||
<PomodoroItem open={() => open('pomodoro')} />
|
||||
<CountdownTimerItem />
|
||||
<NotepadItem open={() => open('notepad')} />
|
||||
<TodoItem open={() => open('todo')} />
|
||||
<BreathingExerciseItem open={() => open('breathing')} />
|
||||
|
||||
<Divider />
|
||||
<ShortcutsItem open={() => open('shortcuts')} />
|
||||
@@ -131,17 +142,23 @@ export function Menu() {
|
||||
show={modals.shareLink}
|
||||
onClose={() => close('shareLink')}
|
||||
/>
|
||||
<BreathingExerciseModal
|
||||
show={modals.breathing}
|
||||
onClose={() => close('breathing')}
|
||||
/>
|
||||
<ShortcutsModal
|
||||
show={modals.shortcuts}
|
||||
onClose={() => close('shortcuts')}
|
||||
/>
|
||||
<PresetsModal show={modals.presets} onClose={() => close('presets')} />
|
||||
<Notepad show={modals.notepad} onClose={() => close('notepad')} />
|
||||
<Pomodoro
|
||||
open={() => open('pomodoro')}
|
||||
show={modals.pomodoro}
|
||||
onClose={() => close('pomodoro')}
|
||||
/>
|
||||
<Notepad show={modals.notepad} onClose={() => close('notepad')} />
|
||||
<Todo show={modals.todo} onClose={() => close('todo')} />
|
||||
<Countdown show={modals.countdown} onClose={() => close('countdown')} />
|
||||
<PresetsModal show={modals.presets} onClose={() => close('presets')} />
|
||||
<SleepTimerModal
|
||||
show={modals.sleepTimer}
|
||||
onClose={() => close('sleepTimer')}
|
||||
|
||||
@@ -14,14 +14,18 @@ interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
lockBody?: boolean;
|
||||
onClose: () => void;
|
||||
persist?: boolean;
|
||||
show: boolean;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
const TRANSITION_DURATION = 300;
|
||||
|
||||
export function Modal({
|
||||
children,
|
||||
lockBody = true,
|
||||
onClose,
|
||||
persist = false,
|
||||
show,
|
||||
wide,
|
||||
}: ModalProps) {
|
||||
@@ -32,9 +36,12 @@ export function Modal({
|
||||
|
||||
useEffect(() => {
|
||||
if (show && lockBody) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflowY = 'hidden';
|
||||
} else if (lockBody) {
|
||||
document.body.style.overflow = 'auto';
|
||||
// Wait for transition to finish before allowing scrollbar to return
|
||||
setTimeout(() => {
|
||||
document.body.style.overflowY = 'auto';
|
||||
}, TRANSITION_DURATION);
|
||||
}
|
||||
}, [show, lockBody]);
|
||||
|
||||
@@ -50,40 +57,51 @@ export function Modal({
|
||||
return () => document.removeEventListener('keydown', keyListener);
|
||||
}, [onClose, show]);
|
||||
|
||||
const animationProps = persist
|
||||
? {
|
||||
animate: show ? 'show' : 'hidden',
|
||||
}
|
||||
: {
|
||||
animate: 'show',
|
||||
exit: 'hidden',
|
||||
initial: 'hidden',
|
||||
};
|
||||
|
||||
const content = (
|
||||
<FocusTrap active={show}>
|
||||
<div>
|
||||
<motion.div
|
||||
{...animationProps}
|
||||
className={styles.overlay}
|
||||
transition={{ duration: TRANSITION_DURATION / 1000 }}
|
||||
variants={variants.overlay}
|
||||
onClick={onClose}
|
||||
onKeyDown={onClose}
|
||||
/>
|
||||
<div className={styles.modal}>
|
||||
<motion.div
|
||||
{...animationProps}
|
||||
className={cn(styles.content, wide && styles.wide)}
|
||||
transition={{ duration: TRANSITION_DURATION / 1000 }}
|
||||
variants={variants.modal}
|
||||
>
|
||||
<button className={styles.close} onClick={onClose}>
|
||||
<IoClose />
|
||||
</button>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<FocusTrap>
|
||||
<div>
|
||||
<motion.div
|
||||
animate="show"
|
||||
className={styles.overlay}
|
||||
exit="hidden"
|
||||
initial="hidden"
|
||||
variants={variants.overlay}
|
||||
onClick={onClose}
|
||||
onKeyDown={onClose}
|
||||
/>
|
||||
<div className={styles.modal}>
|
||||
<motion.div
|
||||
animate="show"
|
||||
className={cn(styles.content, wide && styles.wide)}
|
||||
exit="hidden"
|
||||
initial="hidden"
|
||||
variants={variants.modal}
|
||||
>
|
||||
<button className={styles.close} onClick={onClose}>
|
||||
<IoClose />
|
||||
</button>
|
||||
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{persist ? (
|
||||
<div style={{ display: show ? 'block' : 'none' }}>{content}</div>
|
||||
) : (
|
||||
<AnimatePresence>{show && content}</AnimatePresence>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
1
src/components/modals/breathing/breathing.module.css
Normal file
1
src/components/modals/breathing/breathing.module.css
Normal file
@@ -0,0 +1 @@
|
||||
/* WIP */
|
||||
18
src/components/modals/breathing/breathing.tsx
Normal file
18
src/components/modals/breathing/breathing.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Modal } from '@/components/modal';
|
||||
import { Exercise } from './exercise';
|
||||
|
||||
import styles from './breathing.module.css';
|
||||
|
||||
interface TimerProps {
|
||||
onClose: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function BreathingExerciseModal({ onClose, show }: TimerProps) {
|
||||
return (
|
||||
<Modal show={show} onClose={onClose}>
|
||||
<h2 className={styles.title}>Breathing Exercise</h2>
|
||||
<Exercise />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
91
src/components/modals/breathing/exercise/exercise.module.css
Normal file
91
src/components/modals/breathing/exercise/exercise.module.css
Normal file
@@ -0,0 +1,91 @@
|
||||
.exercise {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 75px 0;
|
||||
margin-top: 12px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
& .timer {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-100),
|
||||
var(--color-neutral-50)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
& .phase {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
& .circle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: -1;
|
||||
height: 55%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-image: radial-gradient(
|
||||
var(--color-neutral-50),
|
||||
var(--color-neutral-100)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.selectWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
padding: 0 12px;
|
||||
margin-top: 8px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
width: 80%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-300),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
& .selectBox {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
& option {
|
||||
color: var(--color-neutral-50);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/components/modals/breathing/exercise/exercise.tsx
Normal file
126
src/components/modals/breathing/exercise/exercise.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { padNumber } from '@/helpers/number';
|
||||
|
||||
import styles from './exercise.module.css';
|
||||
|
||||
type Exercise = 'Box Breathing' | 'Resonant Breathing' | '4-7-8 Breathing';
|
||||
type Phase = 'inhale' | 'exhale' | 'holdInhale' | 'holdExhale';
|
||||
|
||||
const EXERCISE_PHASES: Record<Exercise, Phase[]> = {
|
||||
'4-7-8 Breathing': ['inhale', 'holdInhale', 'exhale'],
|
||||
'Box Breathing': ['inhale', 'holdInhale', 'exhale', 'holdExhale'],
|
||||
'Resonant Breathing': ['inhale', 'exhale'],
|
||||
};
|
||||
|
||||
const EXERCISE_DURATIONS: Record<Exercise, Partial<Record<Phase, number>>> = {
|
||||
'4-7-8 Breathing': { exhale: 8, holdInhale: 7, inhale: 4 },
|
||||
'Box Breathing': { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 },
|
||||
'Resonant Breathing': { exhale: 5, inhale: 5 }, // No holdExhale
|
||||
};
|
||||
|
||||
const PHASE_LABELS: Record<Phase, string> = {
|
||||
exhale: 'Exhale',
|
||||
holdExhale: 'Hold',
|
||||
holdInhale: 'Hold',
|
||||
inhale: 'Inhale',
|
||||
};
|
||||
|
||||
export function Exercise() {
|
||||
const [selectedExercise, setSelectedExercise] =
|
||||
useState<Exercise>('4-7-8 Breathing');
|
||||
const [phaseIndex, setPhaseIndex] = useState(0);
|
||||
|
||||
const phases = useMemo(
|
||||
() => EXERCISE_PHASES[selectedExercise],
|
||||
[selectedExercise],
|
||||
);
|
||||
const durations = useMemo(
|
||||
() => EXERCISE_DURATIONS[selectedExercise],
|
||||
[selectedExercise],
|
||||
);
|
||||
|
||||
const currentPhase = phases[phaseIndex];
|
||||
|
||||
const animationVariants = useMemo(
|
||||
() => ({
|
||||
exhale: {
|
||||
transform: 'translate(-50%, -50%) scale(1)',
|
||||
transition: { duration: durations.exhale },
|
||||
},
|
||||
holdExhale: {
|
||||
transform: 'translate(-50%, -50%) scale(1)',
|
||||
transition: { duration: durations.holdExhale },
|
||||
},
|
||||
holdInhale: {
|
||||
transform: 'translate(-50%, -50%) scale(1.5)',
|
||||
transition: { duration: durations.holdInhale },
|
||||
},
|
||||
inhale: {
|
||||
transform: 'translate(-50%, -50%) scale(1.5)',
|
||||
transition: { duration: durations.inhale },
|
||||
},
|
||||
}),
|
||||
[durations],
|
||||
);
|
||||
|
||||
const resetExercise = useCallback(() => {
|
||||
setPhaseIndex(0);
|
||||
}, []);
|
||||
|
||||
const updatePhase = useCallback(() => {
|
||||
setPhaseIndex(prevIndex => (prevIndex + 1) % phases.length);
|
||||
}, [phases.length]);
|
||||
|
||||
useEffect(() => {
|
||||
resetExercise();
|
||||
}, [selectedExercise, resetExercise]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalDuration = (durations[currentPhase] || 4) * 1000;
|
||||
const interval = setInterval(updatePhase, intervalDuration);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentPhase, durations, updatePhase]);
|
||||
|
||||
const [timer, setTimer] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTimer(prev => prev + 1), 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.exercise}>
|
||||
<div className={styles.timer}>
|
||||
{padNumber(Math.floor(timer / 60))}:{padNumber(timer % 60)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={currentPhase}
|
||||
className={styles.circle}
|
||||
key={selectedExercise}
|
||||
variants={animationVariants}
|
||||
/>
|
||||
<p className={styles.phase}>{PHASE_LABELS[currentPhase]}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.selectWrapper}>
|
||||
<select
|
||||
className={styles.selectBox}
|
||||
value={selectedExercise}
|
||||
onChange={e => setSelectedExercise(e.target.value as Exercise)}
|
||||
>
|
||||
{Object.keys(EXERCISE_PHASES).map(exercise => (
|
||||
<option key={exercise} value={exercise}>
|
||||
{exercise}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/modals/breathing/exercise/index.ts
Normal file
1
src/components/modals/breathing/exercise/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Exercise } from './exercise';
|
||||
1
src/components/modals/breathing/index.ts
Normal file
1
src/components/modals/breathing/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BreathingExerciseModal } from './breathing';
|
||||
@@ -2,7 +2,8 @@ import { FaPlay, FaRegTrashAlt } from 'react-icons/fa/index';
|
||||
|
||||
import styles from './list.module.css';
|
||||
|
||||
import { usePresetStore, useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { usePresetStore } from '@/stores/preset';
|
||||
|
||||
interface ListProps {
|
||||
close: () => void;
|
||||
@@ -25,15 +26,15 @@ export function List({ close }: ListProps) {
|
||||
<p className={styles.empty}>You don't have any presets yet.</p>
|
||||
)}
|
||||
|
||||
{presets.map((preset, index) => (
|
||||
<div className={styles.preset} key={index}>
|
||||
{presets.map(preset => (
|
||||
<div className={styles.preset} key={preset.id}>
|
||||
<input
|
||||
placeholder="Untitled"
|
||||
type="text"
|
||||
value={preset.label}
|
||||
onChange={e => changeName(index, e.target.value)}
|
||||
onChange={e => changeName(preset.id, e.target.value)}
|
||||
/>
|
||||
<button onClick={() => deletePreset(index)}>
|
||||
<button onClick={() => deletePreset(preset.id)}>
|
||||
<FaRegTrashAlt />
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { useSoundStore, usePresetStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { usePresetStore } from '@/stores/preset';
|
||||
|
||||
import styles from './new.module.css';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IoCopyOutline, IoCheckmark } from 'react-icons/io5/index';
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
import { useCopy } from '@/hooks/use-copy';
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import styles from './share-link.module.css';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useSnackbar } from '@/contexts/snackbar';
|
||||
import { useCloseListener } from '@/hooks/use-close-listener';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
justify-content: space-between;
|
||||
|
||||
& .label {
|
||||
font-weight: 500;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,17 @@ export function ShortcutsModal({ onClose, show }: ShortcutsModalProps) {
|
||||
keys: ['Shift', 'S'],
|
||||
label: 'Share Sounds',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'Alt', 'T'],
|
||||
label: 'Sleep Timer',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'C'],
|
||||
label: 'Countdown Timer',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'N'],
|
||||
label: 'Notepad',
|
||||
label: 'Simple Notepad',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'P'],
|
||||
@@ -31,7 +39,11 @@ export function ShortcutsModal({ onClose, show }: ShortcutsModalProps) {
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'T'],
|
||||
label: 'Sleep Timer',
|
||||
label: 'Todo Checklist',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'B'],
|
||||
label: 'Breathing Exercise',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'Space'],
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { Modal } from '@/components/modal';
|
||||
import { Timer } from '@/components/timer';
|
||||
import { dispatch } from '@/lib/event';
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { FADE_OUT } from '@/constants/events';
|
||||
import { useSleepTimerStore } from '@/stores/sleep-timer';
|
||||
|
||||
import styles from './sleep-timer.module.css';
|
||||
|
||||
@@ -15,8 +16,12 @@ interface SleepTimerModalProps {
|
||||
}
|
||||
|
||||
export function SleepTimerModal({ onClose, show }: SleepTimerModalProps) {
|
||||
const setActive = useSleepTimerStore(state => state.set);
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
useEffect(() => setActive(running), [running, setActive]);
|
||||
|
||||
const [hours, setHours] = useState<string>('0');
|
||||
const [minutes, setMinutes] = useState<string>('10');
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
color: var(--color-foreground);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BiShuffle } from 'react-icons/bi/index';
|
||||
|
||||
import { Tooltip } from '@/components/tooltip';
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import styles from './shuffle.module.css';
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { BiHeart, BiSolidHeart } from 'react-icons/bi/index';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { fade } from '@/lib/motion';
|
||||
|
||||
import styles from './favorite.module.css';
|
||||
|
||||
import { useKeyboardButton } from '@/hooks/use-keyboard-button';
|
||||
import { waitUntil } from '@/helpers/wait';
|
||||
|
||||
interface FavoriteProps {
|
||||
id: string;
|
||||
@@ -18,11 +19,25 @@ export function Favorite({ id, label }: FavoriteProps) {
|
||||
const isFavorite = useSoundStore(state => state.sounds[id].isFavorite);
|
||||
const toggleFavorite = useSoundStore(state => state.toggleFavorite);
|
||||
|
||||
const handleToggle = async () => {
|
||||
toggleFavorite(id);
|
||||
|
||||
// Check if false -> true
|
||||
if (!isFavorite) {
|
||||
await waitUntil(
|
||||
() => !!document.getElementById('category-favorites'),
|
||||
50,
|
||||
);
|
||||
|
||||
document
|
||||
.getElementById('category-favorites')
|
||||
?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const variants = fade();
|
||||
|
||||
const handleKeyDown = useKeyboardButton(() => {
|
||||
toggleFavorite(id);
|
||||
});
|
||||
const handleKeyDown = useKeyboardButton(handleToggle);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
@@ -36,11 +51,12 @@ export function Favorite({ id, label }: FavoriteProps) {
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(id);
|
||||
handleToggle();
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
animate="show"
|
||||
aria-hidden="true"
|
||||
exit="hidden"
|
||||
initial="hidden"
|
||||
key={isFavorite ? `${id}-is-favorite` : `${id}-not-favorite`}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import styles from './range.module.css';
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
color: var(--color-foreground-subtler);
|
||||
transition: 0.2s;
|
||||
|
||||
& span {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Range } from './range';
|
||||
import { Favorite } from './favorite';
|
||||
|
||||
import { useSound } from '@/hooks/use-sound';
|
||||
import { useSoundStore, useLoadingStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useLoadingStore } from '@/stores/loading';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
import styles from './sound.module.css';
|
||||
@@ -96,11 +97,11 @@ export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
|
||||
<Favorite id={id} label={label} />
|
||||
<div className={styles.icon}>
|
||||
{isLoading ? (
|
||||
<span className={styles.spinner}>
|
||||
<span aria-hidden="true" className={styles.spinner}>
|
||||
<ImSpinner9 />
|
||||
</span>
|
||||
) : (
|
||||
icon
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.label} id={id}>
|
||||
|
||||
@@ -3,6 +3,10 @@ import { FaGithub } from 'react-icons/fa/index';
|
||||
|
||||
import { SpecialButton } from './special-button';
|
||||
import { Container } from './container';
|
||||
|
||||
import { generateRandomBinaryString } from '@/helpers/binary';
|
||||
|
||||
const binary = generateRandomBinaryString(1000);
|
||||
---
|
||||
|
||||
<div class="source">
|
||||
@@ -15,7 +19,7 @@ import { Container } from './container';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="title">Open Source</h2>
|
||||
<h2 class="title"><span>Open Source</span></h2>
|
||||
<p class="desc">Moodist is free and open-source!</p>
|
||||
|
||||
<div class="button">
|
||||
@@ -23,6 +27,8 @@ import { Container } from './container';
|
||||
Source Code
|
||||
</SpecialButton>
|
||||
</div>
|
||||
|
||||
<div class="binary">{binary}</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
@@ -34,9 +40,28 @@ import { Container } from './container';
|
||||
& .wrapper {
|
||||
position: relative;
|
||||
padding: 0 20px 40px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(transparent, rgb(24 24 27 / 70%));
|
||||
border-radius: 0 0 20px 20px;
|
||||
|
||||
& .binary {
|
||||
position: absolute;
|
||||
bottom: -2.1rem;
|
||||
left: 50%;
|
||||
width: calc(100% + 10px);
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
font-family: 'Nimbus Mono PS', 'Courier New', monospace;
|
||||
color: var(--color-foreground-subtler);
|
||||
word-break: break-all;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
opacity: 0.35;
|
||||
mask-image: linear-gradient(transparent, #000);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -73,7 +98,10 @@ import { Container } from './container';
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
background-color: var(--color-neutral-100);
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-50),
|
||||
var(--color-neutral-100)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -84,6 +112,16 @@ import { Container } from './container';
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
|
||||
& span {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-foreground),
|
||||
var(--color-foreground-subtle)
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& .desc {
|
||||
|
||||
@@ -6,19 +6,20 @@ interface SpecialButtonProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
href: string;
|
||||
internal?: boolean;
|
||||
}
|
||||
|
||||
export function SpecialButton({
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
internal,
|
||||
}: SpecialButtonProps) {
|
||||
return (
|
||||
<a
|
||||
className={cn(styles.button, className)}
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
{...(!internal ? { rel: 'noreferrer', target: '_blank' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useSoundStore, useNoteStore, usePresetStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useNoteStore } from '@/stores/note';
|
||||
import { usePresetStore } from '@/stores/preset';
|
||||
import { useTodoStore } from '@/stores/todo';
|
||||
|
||||
interface StoreConsumerProps {
|
||||
children: React.ReactNode;
|
||||
@@ -11,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
|
||||
useSoundStore.persist.rehydrate();
|
||||
useNoteStore.persist.rehydrate();
|
||||
usePresetStore.persist.rehydrate();
|
||||
useTodoStore.persist.rehydrate();
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -9,4 +9,8 @@
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
&.tall {
|
||||
padding: 60px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { padNumber } from '@/helpers/number';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
import styles from './timer.module.css';
|
||||
|
||||
interface TimerProps {
|
||||
displayHours?: boolean;
|
||||
tall?: boolean;
|
||||
timer: number;
|
||||
}
|
||||
|
||||
export function Timer({ displayHours = false, timer }: TimerProps) {
|
||||
export function Timer({ displayHours = false, tall, timer }: TimerProps) {
|
||||
let hours = Math.floor(timer / 3600);
|
||||
let minutes = Math.floor((timer % 3600) / 60);
|
||||
let seconds = timer % 60;
|
||||
@@ -21,7 +23,7 @@ export function Timer({ displayHours = false, timer }: TimerProps) {
|
||||
const formattedSeconds = padNumber(seconds);
|
||||
|
||||
return (
|
||||
<div className={styles.timer}>
|
||||
<div className={cn(styles.timer, tall && styles.tall)}>
|
||||
{displayHours ? (
|
||||
<>
|
||||
{formattedHours}:{formattedMinutes}:{formattedSeconds}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
z-index: 15;
|
||||
width: 100%;
|
||||
width: 100vw;
|
||||
pointer-events: none;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
|
||||
128
src/components/toolbox/countdown/countdown.module.css
Normal file
128
src/components/toolbox/countdown/countdown.module.css
Normal file
@@ -0,0 +1,128 @@
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
|
||||
& .title {
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
& .desc {
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.formContainer {
|
||||
& .inputContainer {
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
& .input {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
height: 45px;
|
||||
padding: 0 8px;
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
& span {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.displayTime {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 150px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 50%;
|
||||
width: 80%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-300),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
& span {
|
||||
font-size: var(--font-xlg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
& .reverse {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-100),
|
||||
var(--color-neutral-50)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
|
||||
& .button {
|
||||
padding: 12px 16px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground-subtle);
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-200);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: var(--color-neutral-200);
|
||||
background-color: var(--color-neutral-950);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-neutral-800);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/components/toolbox/countdown/countdown.tsx
Normal file
148
src/components/toolbox/countdown/countdown.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
import { useSoundEffect } from '@/hooks/use-sound-effect';
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { padNumber } from '@/helpers/number';
|
||||
|
||||
import styles from './countdown.module.css';
|
||||
|
||||
interface CountdownProps {
|
||||
onClose: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function Countdown({ onClose, show }: CountdownProps) {
|
||||
const [hours, setHours] = useState(0);
|
||||
const [minutes, setMinutes] = useState(0);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [timeLeft, setTimeLeft] = useState(0);
|
||||
const [initialTime, setInitialTime] = useState(0);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isFormVisible, setIsFormVisible] = useState(true);
|
||||
|
||||
const alarm = useSoundEffect('/sounds/alarm.mp3');
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isActive && timeLeft > 0) {
|
||||
timer = setTimeout(() => setTimeLeft(timeLeft - 1), 1000);
|
||||
} else if (timeLeft === 0 && isActive) {
|
||||
alarm.play();
|
||||
setIsActive(false);
|
||||
setIsFormVisible(true);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isActive, timeLeft, alarm]);
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
if (hours > 0 || minutes > 0 || seconds > 0) {
|
||||
const totalTime =
|
||||
(hours || 0) * 3600 + (minutes || 0) * 60 + (seconds || 0);
|
||||
|
||||
setTimeLeft(totalTime);
|
||||
setInitialTime(totalTime);
|
||||
setIsActive(true);
|
||||
setIsFormVisible(false);
|
||||
}
|
||||
}, [hours, minutes, seconds]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setIsFormVisible(true);
|
||||
setTimeLeft(0);
|
||||
}, []);
|
||||
|
||||
const toggleTimer = useCallback(() => {
|
||||
setIsActive(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const formatTime = useCallback((time: number) => {
|
||||
const hrs = Math.floor(time / 3600);
|
||||
const mins = Math.floor((time % 3600) / 60);
|
||||
const secs = time % 60;
|
||||
|
||||
return `${padNumber(hrs)}:${padNumber(mins)}:${padNumber(secs)}`;
|
||||
}, []);
|
||||
|
||||
const elapsedTime = initialTime - timeLeft;
|
||||
|
||||
return (
|
||||
<Modal show={show} onClose={onClose}>
|
||||
<header className={styles.header}>
|
||||
<h2 className={styles.title}>Countdown Timer</h2>
|
||||
<p className={styles.desc}>Super simple countdown timer.</p>
|
||||
</header>
|
||||
|
||||
{isFormVisible ? (
|
||||
<div className={styles.formContainer}>
|
||||
<div className={styles.inputContainer}>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="HH"
|
||||
type="number"
|
||||
value={hours}
|
||||
onChange={e => setHours(Math.max(0, parseInt(e.target.value)))}
|
||||
/>
|
||||
|
||||
<span>:</span>
|
||||
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="MM"
|
||||
type="number"
|
||||
value={minutes}
|
||||
onChange={e =>
|
||||
setMinutes(Math.max(0, Math.min(59, parseInt(e.target.value))))
|
||||
}
|
||||
/>
|
||||
|
||||
<span>:</span>
|
||||
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="SS"
|
||||
type="number"
|
||||
value={seconds}
|
||||
onChange={e =>
|
||||
setSeconds(Math.max(0, Math.min(59, parseInt(e.target.value))))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<button
|
||||
className={cn(styles.button, styles.primary)}
|
||||
onClick={handleStart}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.timerContainer}>
|
||||
<div className={styles.displayTime}>
|
||||
<p className={styles.reverse}>- {formatTime(elapsedTime)}</p>
|
||||
<span>{formatTime(timeLeft)}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<button className={styles.button} onClick={handleBack}>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={cn(styles.button, styles.primary)}
|
||||
onClick={toggleTimer}
|
||||
>
|
||||
{isActive ? 'Pause' : 'Start'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
1
src/components/toolbox/countdown/index.ts
Normal file
1
src/components/toolbox/countdown/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Countdown } from './countdown';
|
||||
@@ -1,2 +1,4 @@
|
||||
export { Notepad } from './notepad';
|
||||
export { Pomodoro } from './pomodoro';
|
||||
export { Todo } from './todo';
|
||||
export { Countdown } from './countdown';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FaUndo } from 'react-icons/fa/index';
|
||||
import { Modal } from '@/components/modal';
|
||||
import { Button } from './button';
|
||||
|
||||
import { useNoteStore } from '@/store';
|
||||
import { useNoteStore } from '@/stores/note';
|
||||
import { useCopy } from '@/hooks/use-copy';
|
||||
import { download } from '@/helpers/download';
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Setting } from './setting';
|
||||
|
||||
import { useLocalStorage } from '@/hooks/use-local-storage';
|
||||
import { useSoundEffect } from '@/hooks/use-sound-effect';
|
||||
import { usePomodoroStore } from '@/store';
|
||||
import { usePomodoroStore } from '@/stores/pomodoro';
|
||||
import { useCloseListener } from '@/hooks/use-close-listener';
|
||||
|
||||
import styles from './pomodoro.module.css';
|
||||
|
||||
35
src/components/toolbox/todo/form/form.module.css
Normal file
35
src/components/toolbox/todo/form/form.module.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 45px;
|
||||
padding: 4px;
|
||||
margin-top: 12px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
& input {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
color: var(--color-foreground);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
33
src/components/toolbox/todo/form/form.tsx
Normal file
33
src/components/toolbox/todo/form/form.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useTodoStore } from '@/stores/todo';
|
||||
|
||||
import styles from './form.module.css';
|
||||
|
||||
export function Form() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const addTodo = useTodoStore(state => state.addTodo);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!value.trim().length) return;
|
||||
|
||||
addTodo(value);
|
||||
setValue('');
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={styles.wrapper}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
/>
|
||||
<button type="submit">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
1
src/components/toolbox/todo/form/index.ts
Normal file
1
src/components/toolbox/todo/form/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Form } from './form';
|
||||
1
src/components/toolbox/todo/index.ts
Normal file
1
src/components/toolbox/todo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Todo } from './todo';
|
||||
1
src/components/toolbox/todo/todo.module.css
Normal file
1
src/components/toolbox/todo/todo.module.css
Normal file
@@ -0,0 +1 @@
|
||||
/* WIP */
|
||||
20
src/components/toolbox/todo/todo.tsx
Normal file
20
src/components/toolbox/todo/todo.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Modal } from '@/components/modal';
|
||||
import { Form } from './form';
|
||||
import { Todos } from './todos';
|
||||
|
||||
import styles from './todo.module.css';
|
||||
|
||||
interface TodoProps {
|
||||
onClose: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function Todo({ onClose, show }: TodoProps) {
|
||||
return (
|
||||
<Modal show={show} onClose={onClose}>
|
||||
<h2 className={styles.title}>Todos</h2>
|
||||
<Form />
|
||||
<Todos />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
1
src/components/toolbox/todo/todos/index.ts
Normal file
1
src/components/toolbox/todo/todos/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Todos } from './todos';
|
||||
1
src/components/toolbox/todo/todos/todo/index.ts
Normal file
1
src/components/toolbox/todo/todos/todo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Todo } from './todo';
|
||||
45
src/components/toolbox/todo/todos/todo/todo.module.css
Normal file
45
src/components/toolbox/todo/todos/todo/todo.module.css
Normal file
@@ -0,0 +1,45 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
height: 45px;
|
||||
padding: 4px;
|
||||
margin-top: 12px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
& .checkbox {
|
||||
display: block;
|
||||
margin: 0 8px 0 4px;
|
||||
}
|
||||
|
||||
& .textbox {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&.done {
|
||||
color: var(--color-foreground-subtle);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
& button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
color: #f43f5e;
|
||||
cursor: pointer;
|
||||
background-color: rgb(244 63 94 / 15%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
41
src/components/toolbox/todo/todos/todo/todo.tsx
Normal file
41
src/components/toolbox/todo/todos/todo/todo.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FaRegTrashAlt } from 'react-icons/fa/index';
|
||||
|
||||
import { useTodoStore } from '@/stores/todo';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
import styles from './todo.module.css';
|
||||
|
||||
interface TodoProps {
|
||||
done: boolean;
|
||||
id: string;
|
||||
todo: string;
|
||||
}
|
||||
|
||||
export function Todo({ done, id, todo }: TodoProps) {
|
||||
const deleteTodo = useTodoStore(state => state.deleteTodo);
|
||||
const toggleTodo = useTodoStore(state => state.toggleTodo);
|
||||
const editTodo = useTodoStore(state => state.editTodo);
|
||||
|
||||
const handleCheck = () => toggleTodo(id);
|
||||
const handleDelete = () => deleteTodo(id);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<input
|
||||
checked={done}
|
||||
className={styles.checkbox}
|
||||
type="checkbox"
|
||||
onChange={handleCheck}
|
||||
/>
|
||||
<input
|
||||
className={cn(styles.textbox, done && styles.done)}
|
||||
type="text"
|
||||
value={todo}
|
||||
onChange={e => editTodo(id, e.target.value)}
|
||||
/>
|
||||
<button onClick={handleDelete}>
|
||||
<FaRegTrashAlt />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/components/toolbox/todo/todos/todos.module.css
Normal file
1
src/components/toolbox/todo/todos/todos.module.css
Normal file
@@ -0,0 +1 @@
|
||||
/* WIP */
|
||||
17
src/components/toolbox/todo/todos/todos.tsx
Normal file
17
src/components/toolbox/todo/todos/todos.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Todo } from './todo';
|
||||
|
||||
import { useTodoStore } from '@/stores/todo';
|
||||
|
||||
import styles from './todos.module.css';
|
||||
|
||||
export function Todos() {
|
||||
const todos = useTodoStore(state => state.todos);
|
||||
|
||||
return (
|
||||
<div className={styles.todos}>
|
||||
{todos.map(todo => (
|
||||
<Todo done={todo.done} id={todo.id} key={todo.id} todo={todo.todo} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export const FADE_OUT = 'FADE_OUT';
|
||||
export const CLOSE_MODALS = 'CLOSE_MODALS';
|
||||
export const FADE_OUT = 'FADE_OUT'; // Fade out sounds
|
||||
export const CLOSE_MODALS = 'CLOSE_MODALS'; // Close all modals
|
||||
|
||||
9
src/helpers/binary.ts
Normal file
9
src/helpers/binary.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function generateRandomBinaryString(length: number) {
|
||||
let binaryString = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
binaryString += Math.floor(Math.random() * 2);
|
||||
}
|
||||
|
||||
return binaryString;
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Counts the number of characters and words in a given string.
|
||||
*
|
||||
* @param {string} _string - The input string to be analyzed.
|
||||
* @returns {{characters: number, words: number}} An object containing the counts:
|
||||
* - characters: The number of non-whitespace characters in the input string.
|
||||
* - words: The number of words in the input string.
|
||||
*/
|
||||
export function count(_string: string) {
|
||||
const string = _string.trim();
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Triggers a download of a file with the specified filename and content.
|
||||
*
|
||||
* @param {string} filename - The name of the file to be downloaded.
|
||||
* @param {string} content - The content to be included in the downloaded file.
|
||||
*/
|
||||
export function download(filename: string, content: string) {
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute(
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* Pads a given number with leading zeros to ensure it reaches a specified length.
|
||||
*
|
||||
* @param {number} number - The number to be padded.
|
||||
* @param {number} [maxLength=2] - The desired length of the resulting string. Defaults to 2 if not provided.
|
||||
* @returns {string} The padded number as a string.
|
||||
*/
|
||||
export function padNumber(number: number, maxLength: number = 2): string {
|
||||
return number.toString().padStart(maxLength, '0');
|
||||
const isNegative = number < 0;
|
||||
const absoluteNumber = Math.abs(number).toString();
|
||||
const paddedNumber = absoluteNumber.padStart(maxLength, '0');
|
||||
|
||||
return isNegative ? `-${paddedNumber}` : paddedNumber;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,63 @@
|
||||
/**
|
||||
* Generates a random number between the specified minimum and maximum values.
|
||||
*
|
||||
* @param {number} min - The minimum value (inclusive).
|
||||
* @param {number} max - The maximum value (exclusive).
|
||||
* @returns {number} A random number between min (inclusive) and max (exclusive).
|
||||
*/
|
||||
export function random(min: number, max: number): number {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random integer between the specified minimum and maximum values.
|
||||
*
|
||||
* @param {number} min - The minimum value (inclusive).
|
||||
* @param {number} max - The maximum value (exclusive).
|
||||
* @returns {number} A random integer between min (inclusive) and max (exclusive).
|
||||
*/
|
||||
export function randomInt(min: number, max: number): number {
|
||||
return Math.floor(random(min, max));
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a random element from the given array.
|
||||
*
|
||||
* @template T
|
||||
* @param {Array<T>} array - The array to pick an element from.
|
||||
* @returns {T} A random element from the array.
|
||||
*/
|
||||
export function pick<T>(array: Array<T>): T {
|
||||
const randomIndex = random(0, array.length);
|
||||
if (array.length === 0) {
|
||||
throw new Error("The array shouldn't be empty");
|
||||
}
|
||||
|
||||
const randomIndex = randomInt(0, array.length);
|
||||
|
||||
return array[randomIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a specified number of random elements from the given array.
|
||||
*
|
||||
* @template T
|
||||
* @param {Array<T>} array - The array to pick elements from.
|
||||
* @param {number} count - The number of elements to pick.
|
||||
* @returns {Array<T>} An array containing the picked elements.
|
||||
*/
|
||||
export function pickMany<T>(array: Array<T>, count: number): Array<T> {
|
||||
const shuffled = shuffle(array);
|
||||
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles the elements of the given array in random order.
|
||||
*
|
||||
* @template T
|
||||
* @param {Array<T>} array - The array to shuffle.
|
||||
* @returns {Array<T>} The shuffled array.
|
||||
*/
|
||||
export function shuffle<T>(array: Array<T>): Array<T> {
|
||||
return array
|
||||
.map(value => ({ sort: Math.random(), value }))
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
type className = undefined | null | false | string;
|
||||
|
||||
/**
|
||||
* Combines multiple class names into a single string, filtering out invalid values.
|
||||
*
|
||||
* @param {...(undefined|null|false|string)} classNames - The class names to be combined.
|
||||
* @returns {string} A single string containing all valid class names separated by spaces.
|
||||
*/
|
||||
export function cn(...classNames: Array<className>): string {
|
||||
const className = classNames.filter(className => !!className).join(' ');
|
||||
|
||||
|
||||
53
src/helpers/tests/counter.test.ts
Normal file
53
src/helpers/tests/counter.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { count } from '../counter';
|
||||
|
||||
describe('count function', () => {
|
||||
it('should count characters and words in an empty string', () => {
|
||||
const result = count('');
|
||||
expect(result.characters).toBe(0);
|
||||
expect(result.words).toBe(0);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with multiple words', () => {
|
||||
const result = count('Hello world');
|
||||
expect(result.characters).toBe(10);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with multiple spaces', () => {
|
||||
const result = count(' Hello world ');
|
||||
expect(result.characters).toBe(10);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with newlines', () => {
|
||||
const result = count('Hello\nworld');
|
||||
expect(result.characters).toBe(10);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with special characters', () => {
|
||||
const result = count('Hello, world!');
|
||||
expect(result.characters).toBe(12);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with only spaces', () => {
|
||||
const result = count(' ');
|
||||
expect(result.characters).toBe(0);
|
||||
expect(result.words).toBe(0);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with a single word', () => {
|
||||
const result = count('Vitest');
|
||||
expect(result.characters).toBe(6);
|
||||
expect(result.words).toBe(1);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with multiple lines and spaces', () => {
|
||||
const result = count(' Hello \n world ');
|
||||
expect(result.characters).toBe(10);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
});
|
||||
49
src/helpers/tests/number.test.ts
Normal file
49
src/helpers/tests/number.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { padNumber } from '../number';
|
||||
|
||||
describe('padNumber function', () => {
|
||||
it('should pad a single digit number to two digits by default', () => {
|
||||
const result = padNumber(5);
|
||||
expect(result).toBe('05');
|
||||
});
|
||||
|
||||
it('should not pad a number that already has two digits by default', () => {
|
||||
const result = padNumber(12);
|
||||
expect(result).toBe('12');
|
||||
});
|
||||
|
||||
it('should pad a number to the specified length', () => {
|
||||
const result = padNumber(7, 4);
|
||||
expect(result).toBe('0007');
|
||||
});
|
||||
|
||||
it('should not pad a number that already meets the specified length', () => {
|
||||
const result = padNumber(1234, 4);
|
||||
expect(result).toBe('1234');
|
||||
});
|
||||
|
||||
it('should pad a number that has more digits than the specified length', () => {
|
||||
const result = padNumber(123, 5);
|
||||
expect(result).toBe('00123');
|
||||
});
|
||||
|
||||
it('should handle zero correctly', () => {
|
||||
const result = padNumber(0, 3);
|
||||
expect(result).toBe('000');
|
||||
});
|
||||
|
||||
it('should pad negative numbers correctly', () => {
|
||||
const result = padNumber(-5, 3);
|
||||
expect(result).toBe('-005');
|
||||
});
|
||||
|
||||
it('should handle very large padding lengths', () => {
|
||||
const result = padNumber(42, 10);
|
||||
expect(result).toBe('0000000042');
|
||||
});
|
||||
|
||||
it('should handle the maximum length being less than the number length', () => {
|
||||
const result = padNumber(12345, 3);
|
||||
expect(result).toBe('12345');
|
||||
});
|
||||
});
|
||||
89
src/helpers/tests/random.test.ts
Normal file
89
src/helpers/tests/random.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { random, randomInt, pick, pickMany, shuffle } from '../random';
|
||||
|
||||
describe('random function', () => {
|
||||
it('should generate a number between min (inclusive) and max (exclusive)', () => {
|
||||
const min = 1;
|
||||
const max = 10;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const result = random(min, max);
|
||||
expect(result).toBeGreaterThanOrEqual(min);
|
||||
expect(result).toBeLessThan(max);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('randomInt function', () => {
|
||||
it('should generate an integer between min (inclusive) and max (exclusive)', () => {
|
||||
const min = 1;
|
||||
const max = 10;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const result = randomInt(min, max);
|
||||
expect(result).toBeGreaterThanOrEqual(min);
|
||||
expect(result).toBeLessThan(max);
|
||||
expect(Number.isInteger(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('pick function', () => {
|
||||
it('should pick a random element from the array', () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const result = pick(array);
|
||||
expect(array).toContain(result);
|
||||
});
|
||||
|
||||
it('should handle an array with one element', () => {
|
||||
const array = [1];
|
||||
const result = pick(array);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw an error when picking from an empty array', () => {
|
||||
const array: unknown[] = [];
|
||||
expect(() => pick(array)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickMany function', () => {
|
||||
it('should pick the specified number of random elements from the array', () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const count = 3;
|
||||
const result = pickMany(array, count);
|
||||
expect(result).toHaveLength(count);
|
||||
result.forEach(element => {
|
||||
expect(array).toContain(element);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle picking more elements than in the array', () => {
|
||||
const array = [1, 2, 3];
|
||||
const count = 5;
|
||||
const result = pickMany(array, count);
|
||||
expect(result).toHaveLength(array.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffle function', () => {
|
||||
it('should shuffle the elements of the array', () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const result = shuffle(array);
|
||||
expect(result).toHaveLength(array.length);
|
||||
expect(result).not.toEqual(array); // It's possible for the arrays to be equal, but this is highly unlikely
|
||||
array.forEach(element => {
|
||||
expect(result).toContain(element);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle an empty array', () => {
|
||||
const array: unknown[] = [];
|
||||
const result = shuffle(array);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle an array with one element', () => {
|
||||
const array = [1];
|
||||
const result = shuffle(array);
|
||||
expect(result).toEqual(array);
|
||||
});
|
||||
});
|
||||
49
src/helpers/tests/styles.test.ts
Normal file
49
src/helpers/tests/styles.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cn } from '../styles';
|
||||
|
||||
describe('cn function', () => {
|
||||
it('should return an empty string when no arguments are provided', () => {
|
||||
const result = cn();
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return an empty string when all arguments are invalid values', () => {
|
||||
const result = cn(undefined, null, false, '');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return a single class name when one valid string is provided', () => {
|
||||
const result = cn('class1');
|
||||
expect(result).toBe('class1');
|
||||
});
|
||||
|
||||
it('should combine multiple class names into a single string separated by spaces', () => {
|
||||
const result = cn('class1', 'class2', 'class3');
|
||||
expect(result).toBe('class1 class2 class3');
|
||||
});
|
||||
|
||||
it('should filter out invalid values and combine valid class names', () => {
|
||||
const result = cn('class1', undefined, 'class2', null, false, 'class3', '');
|
||||
expect(result).toBe('class1 class2 class3');
|
||||
});
|
||||
|
||||
it('should handle a mix of valid and invalid class names', () => {
|
||||
const result = cn('class1', '', false, null, 'class2');
|
||||
expect(result).toBe('class1 class2');
|
||||
});
|
||||
|
||||
it('should return an empty string when all class names are empty strings', () => {
|
||||
const result = cn('', '', '');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle single class name with leading and trailing spaces', () => {
|
||||
const result = cn(' class1 ');
|
||||
expect(result).toBe(' class1 ');
|
||||
});
|
||||
|
||||
it('should handle class names with spaces in between', () => {
|
||||
const result = cn('class1 class2', 'class3 class4');
|
||||
expect(result).toBe('class1 class2 class3 class4');
|
||||
});
|
||||
});
|
||||
67
src/helpers/tests/wait.test.ts
Normal file
67
src/helpers/tests/wait.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { waitUntil } from '../wait';
|
||||
|
||||
describe('waitUntil function', () => {
|
||||
it('should resolve when the function returns true', async () => {
|
||||
const mockFunc = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true);
|
||||
|
||||
await waitUntil(mockFunc, 50);
|
||||
expect(mockFunc).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should reject if the function throws an error', async () => {
|
||||
const mockFunc = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(false)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
|
||||
await expect(waitUntil(mockFunc, 50)).rejects.toThrow('Test error');
|
||||
expect(mockFunc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should repeatedly call the function at the specified interval', async () => {
|
||||
const mockFunc = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true);
|
||||
|
||||
const interval = 100;
|
||||
const startTime = Date.now();
|
||||
|
||||
await waitUntil(mockFunc, interval);
|
||||
|
||||
const endTime = Date.now();
|
||||
const elapsedTime = endTime - startTime;
|
||||
|
||||
expect(elapsedTime).toBeGreaterThanOrEqual(2 * interval);
|
||||
expect(mockFunc).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should handle the function returning true on the first call', async () => {
|
||||
const mockFunc = vi.fn().mockReturnValueOnce(true);
|
||||
|
||||
await waitUntil(mockFunc, 50);
|
||||
expect(mockFunc).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle the function never returning true (timeout simulation)', async () => {
|
||||
const mockFunc = vi.fn().mockReturnValue(false);
|
||||
|
||||
// Using a very short timeout to simulate test timeout
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Test timeout')), 300),
|
||||
);
|
||||
|
||||
await expect(
|
||||
Promise.race([waitUntil(mockFunc, 50), timeoutPromise]),
|
||||
).rejects.toThrow('Test timeout');
|
||||
expect(mockFunc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
27
src/helpers/wait.ts
Normal file
27
src/helpers/wait.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Repeatedly calls a function at a specified interval until it returns `true`.
|
||||
*
|
||||
* @param {() => boolean} func - A function that returns a boolean. The interval will continue until this function returns `true`.
|
||||
* @param {number} interval - The time, in milliseconds, between each call to `func`.
|
||||
* @returns {Promise<void>} A promise that resolves when `func` returns `true`, or rejects if an error is thrown during execution of `func`.
|
||||
*/
|
||||
export function waitUntil(
|
||||
func: () => boolean,
|
||||
interval: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const intervalId = setInterval(() => {
|
||||
try {
|
||||
const result = func();
|
||||
|
||||
if (result) {
|
||||
clearInterval(intervalId);
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(intervalId);
|
||||
reject(error);
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { useEffect } from 'react';
|
||||
|
||||
import { onCloseModals } from '@/lib/modal';
|
||||
|
||||
/**
|
||||
* A custom React hook that registers a listener function to be called when modals are to be closed.
|
||||
*
|
||||
* @param {Function} listener - The function to be called when modals are to be closed.
|
||||
*/
|
||||
export function useCloseListener(listener: () => void) {
|
||||
useEffect(() => {
|
||||
const unsubscribe = onCloseModals(listener);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* A custom React hook to copy text to the clipboard with a temporary state indication.
|
||||
*
|
||||
* @param {number} [timeout=1500] - The duration in milliseconds for which the `copying` state remains true. Defaults to 1500 milliseconds.
|
||||
* @returns {{ copy: (content: string) => void, copying: boolean }} An object containing:
|
||||
* - copy: The function to copy content to the clipboard.
|
||||
* - copying: A boolean indicating if a copy operation is in progress.
|
||||
*/
|
||||
export function useCopy(timeout = 1500) {
|
||||
const [copying, setCopying] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
/**
|
||||
* A custom React hook that creates a keyboard event handler for 'Enter' and 'Space' keys.
|
||||
*
|
||||
* @param {Function} actionCallback - The function to be called when 'Enter' or 'Space' is pressed.
|
||||
* @returns {Function} A keyboard event handler function that triggers the action callback.
|
||||
*/
|
||||
export const useKeyboardButton = (
|
||||
actionCallback: () => void,
|
||||
): ((event: KeyboardEvent<HTMLElement>) => void) => {
|
||||
|
||||
@@ -2,6 +2,14 @@ import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
type SetValue<T> = Dispatch<SetStateAction<T>>;
|
||||
|
||||
/**
|
||||
* A custom React hook to manage state with localStorage persistence.
|
||||
*
|
||||
* @template T
|
||||
* @param {string} key - The key under which the value is stored in localStorage.
|
||||
* @param {T} fallback - The fallback value to use if there is no value in localStorage.
|
||||
* @returns {[T, SetValue<T>]} An array containing the stateful value and a function to update it.
|
||||
*/
|
||||
export function useLocalStorage<T>(key: string, fallback: T): [T, SetValue<T>] {
|
||||
const [value, setValue] = useState(fallback);
|
||||
|
||||
|
||||
@@ -3,6 +3,16 @@ import { Howl } from 'howler';
|
||||
|
||||
import { useSSR } from './use-ssr';
|
||||
|
||||
/**
|
||||
* A custom React hook to manage sound effects using Howler.js.
|
||||
*
|
||||
* @param {string} src - The source URL of the sound file.
|
||||
* @param {number} [volume=1] - The initial volume of the sound, ranging from 0.0 to 1.0.
|
||||
* @returns {{ play: () => void, stop: () => void, pause: () => void }} An object containing control functions for the sound:
|
||||
* - play: Function to play the sound.
|
||||
* - stop: Function to stop the sound.
|
||||
* - pause: Function to pause the sound.
|
||||
*/
|
||||
export function useSoundEffect(src: string, volume: number = 1) {
|
||||
const { isBrowser } = useSSR();
|
||||
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { useMemo, useEffect, useCallback, useState } from 'react';
|
||||
import { Howl } from 'howler';
|
||||
|
||||
import { useLoadingStore } from '@/store';
|
||||
import { useLoadingStore } from '@/stores/loading';
|
||||
import { subscribe } from '@/lib/event';
|
||||
import { useSSR } from './use-ssr';
|
||||
import { FADE_OUT } from '@/constants/events';
|
||||
|
||||
/**
|
||||
* A custom React hook to manage sound playback using Howler.js with additional features.
|
||||
*
|
||||
* This hook initializes a Howl instance for playing sound effects in the browser,
|
||||
* and provides control functions to play, stop, pause, and fade out the sound.
|
||||
* It also handles loading state management and supports event subscription for fade-out effects.
|
||||
*
|
||||
* @param {string} src - The source URL of the sound file.
|
||||
* @param {Object} [options] - Options for sound playback.
|
||||
* @param {boolean} [options.loop=false] - Whether the sound should loop.
|
||||
* @param {number} [options.volume=0.5] - The initial volume of the sound, ranging from 0.0 to 1.0.
|
||||
* @returns {{ play: () => void, stop: () => void, pause: () => void, fadeOut: (duration: number) => void, isLoading: boolean }} An object containing control functions for the sound:
|
||||
* - play: Function to play the sound.
|
||||
* - stop: Function to stop the sound.
|
||||
* - pause: Function to pause the sound.
|
||||
* - fadeOut: Function to fade out the sound over a given duration.
|
||||
* - isLoading: A boolean indicating if the sound is currently loading.
|
||||
*/
|
||||
export function useSound(
|
||||
src: string,
|
||||
options: { loop?: boolean; volume?: number } = {},
|
||||
options: { loop?: boolean; preload?: boolean; volume?: number } = {},
|
||||
html5: boolean = false,
|
||||
) {
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const isLoading = useLoadingStore(state => state.loaders[src]);
|
||||
@@ -20,17 +39,18 @@ export function useSound(
|
||||
|
||||
if (isBrowser) {
|
||||
sound = new Howl({
|
||||
html5,
|
||||
onload: () => {
|
||||
setIsLoading(src, false);
|
||||
setHasLoaded(true);
|
||||
},
|
||||
preload: false,
|
||||
preload: options.preload ?? false,
|
||||
src: src,
|
||||
});
|
||||
}
|
||||
|
||||
return sound;
|
||||
}, [src, isBrowser, setIsLoading]);
|
||||
}, [src, isBrowser, setIsLoading, html5, options.preload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sound) {
|
||||
@@ -42,18 +62,23 @@ export function useSound(
|
||||
if (sound) sound.volume(options.volume ?? 0.5);
|
||||
}, [sound, options.volume]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (sound) {
|
||||
if (!hasLoaded && !isLoading) {
|
||||
setIsLoading(src, true);
|
||||
sound.load();
|
||||
}
|
||||
const play = useCallback(
|
||||
(cb?: () => void) => {
|
||||
if (sound) {
|
||||
if (!hasLoaded && !isLoading) {
|
||||
setIsLoading(src, true);
|
||||
sound.load();
|
||||
}
|
||||
|
||||
if (!sound.playing()) {
|
||||
sound.play();
|
||||
if (!sound.playing()) {
|
||||
sound.play();
|
||||
}
|
||||
|
||||
if (typeof cb === 'function') sound.once('end', cb);
|
||||
}
|
||||
}
|
||||
}, [src, setIsLoading, sound, hasLoaded, isLoading]);
|
||||
},
|
||||
[src, setIsLoading, sound, hasLoaded, isLoading],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (sound) sound.stop();
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* A custom React hook to determine if the code is running in a browser or server environment.
|
||||
*
|
||||
* @returns {{ isBrowser: boolean, isServer: boolean }} An object containing:
|
||||
* - isBrowser: A boolean indicating if the code is running in a browser environment.
|
||||
* - isServer: A boolean indicating if the code is running in a server environment.
|
||||
*/
|
||||
export function useSSR() {
|
||||
const isDOM =
|
||||
typeof window !== 'undefined' &&
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
/**
|
||||
* Dispatches a custom event with an optional detail payload.
|
||||
*
|
||||
* @template T
|
||||
* @param {string} eventName - The name of the event to be dispatched.
|
||||
* @param {T} [detail] - Optional data to be passed with the event.
|
||||
*/
|
||||
export function dispatch<T>(eventName: string, detail?: T) {
|
||||
const event = new CustomEvent(eventName, { detail });
|
||||
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a listener function to a custom event.
|
||||
*
|
||||
* @template T
|
||||
* @param {string} eventName - The name of the event to listen for.
|
||||
* @param {(e: T) => void} listener - The function to be called when the event is dispatched.
|
||||
* @returns {Function} A function to unsubscribe the listener from the event.
|
||||
*/
|
||||
export function subscribe<T>(eventName: string, listener: (e: T) => void) {
|
||||
const handler = (event: Event) => {
|
||||
if ('detail' in event) {
|
||||
@@ -18,6 +33,12 @@ export function subscribe<T>(eventName: string, listener: (e: T) => void) {
|
||||
return () => unsubscribe(eventName, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes a listener function from a custom event.
|
||||
*
|
||||
* @param {string} eventName - The name of the event to unsubscribe from.
|
||||
* @param {(e: Event) => void} listener - The function to be removed from the event listeners.
|
||||
*/
|
||||
export function unsubscribe(eventName: string, listener: (e: Event) => void) {
|
||||
document.removeEventListener(eventName, listener);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { dispatch, subscribe } from './event';
|
||||
import { CLOSE_MODALS } from '@/constants/events';
|
||||
|
||||
/**
|
||||
* Dispatches the CLOSE_MODALS event to signal that modals should be closed.
|
||||
*/
|
||||
export function closeModals() {
|
||||
dispatch(CLOSE_MODALS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a listener function to the CLOSE_MODALS event.
|
||||
*
|
||||
* @param {() => void} listener - The function to be called when the CLOSE_MODALS event is dispatched.
|
||||
* @returns {Function} A function to unsubscribe the listener from the CLOSE_MODALS event.
|
||||
*/
|
||||
export function onCloseModals(listener: () => void) {
|
||||
const unsubscribe = subscribe(CLOSE_MODALS, listener);
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ type Motion = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a fade motion object with opacity transition.
|
||||
*
|
||||
* @returns {Motion} An object containing the hidden and show states for a fade transition.
|
||||
*/
|
||||
export function fade(): Motion {
|
||||
return {
|
||||
hidden: { opacity: 0 },
|
||||
@@ -14,6 +19,13 @@ export function fade(): Motion {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scale motion object with scaling transition.
|
||||
*
|
||||
* @param {number} [from=0.85] - The initial scale value for the hidden state.
|
||||
* @param {number} [to=1] - The final scale value for the show state.
|
||||
* @returns {Motion} An object containing the hidden and show states for a scale transition.
|
||||
*/
|
||||
export function scale(from = 0.85, to = 1): Motion {
|
||||
return {
|
||||
hidden: { scale: from },
|
||||
@@ -21,6 +33,13 @@ export function scale(from = 0.85, to = 1): Motion {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a slide motion object with horizontal sliding transition.
|
||||
*
|
||||
* @param {number} [from=-10] - The initial x position for the hidden state.
|
||||
* @param {number} [to=0] - The final x position for the show state.
|
||||
* @returns {Motion} An object containing the hidden and show states for a horizontal slide transition.
|
||||
*/
|
||||
export function slideX(from = -10, to = 0): Motion {
|
||||
return {
|
||||
hidden: { x: from },
|
||||
@@ -28,6 +47,13 @@ export function slideX(from = -10, to = 0): Motion {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a slide motion object with vertical sliding transition.
|
||||
*
|
||||
* @param {number} [from=-10] - The initial y position for the hidden state.
|
||||
* @param {number} [to=0] - The final y position for the show state.
|
||||
* @returns {Motion} An object containing the hidden and show states for a vertical slide transition.
|
||||
*/
|
||||
export function slideY(from = -10, to = 0): Motion {
|
||||
return {
|
||||
hidden: { y: from },
|
||||
@@ -35,6 +61,15 @@ export function slideY(from = -10, to = 0): Motion {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple motion objects into a single motion object.
|
||||
*
|
||||
* This function merges the hidden and show states of the provided motion objects
|
||||
* into a single motion object.
|
||||
*
|
||||
* @param {...Motion} motions - The motion objects to be combined.
|
||||
* @returns {Motion} An object containing the combined hidden and show states.
|
||||
*/
|
||||
export function mix(...motions: Array<Motion>): Motion {
|
||||
let hidden = {};
|
||||
let show = {};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { sounds } from '@/data/sounds';
|
||||
|
||||
/**
|
||||
* Counts the total number of sounds across all categories.
|
||||
*
|
||||
* @param {boolean} [round=false] - Whether to round the count down to the nearest multiple of 5.
|
||||
* @returns {number} The total count of sounds, optionally rounded down.
|
||||
*/
|
||||
export function count(round: boolean = false) {
|
||||
let count = 0;
|
||||
|
||||
|
||||
99
src/lib/tests/motion.test.ts
Normal file
99
src/lib/tests/motion.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fade, scale, slideX, slideY, mix } from '../motion';
|
||||
|
||||
describe('fade function', () => {
|
||||
it('should create a fade motion object with default opacity values', () => {
|
||||
const result = fade();
|
||||
expect(result).toEqual({
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('scale function', () => {
|
||||
it('should create a scale motion object with default values', () => {
|
||||
const result = scale();
|
||||
expect(result).toEqual({
|
||||
hidden: { scale: 0.85 },
|
||||
show: { scale: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a scale motion object with custom values', () => {
|
||||
const result = scale(0.5, 1.5);
|
||||
expect(result).toEqual({
|
||||
hidden: { scale: 0.5 },
|
||||
show: { scale: 1.5 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('slideX function', () => {
|
||||
it('should create a slide motion object with default x values', () => {
|
||||
const result = slideX();
|
||||
expect(result).toEqual({
|
||||
hidden: { x: -10 },
|
||||
show: { x: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a slide motion object with custom x values', () => {
|
||||
const result = slideX(-20, 10);
|
||||
expect(result).toEqual({
|
||||
hidden: { x: -20 },
|
||||
show: { x: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('slideY function', () => {
|
||||
it('should create a slide motion object with default y values', () => {
|
||||
const result = slideY();
|
||||
expect(result).toEqual({
|
||||
hidden: { y: -10 },
|
||||
show: { y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a slide motion object with custom y values', () => {
|
||||
const result = slideY(-20, 10);
|
||||
expect(result).toEqual({
|
||||
hidden: { y: -20 },
|
||||
show: { y: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mix function', () => {
|
||||
it('should combine multiple motion objects into a single motion object', () => {
|
||||
const fadeMotion = fade();
|
||||
const scaleMotion = scale();
|
||||
const result = mix(fadeMotion, scaleMotion);
|
||||
expect(result).toEqual({
|
||||
hidden: { opacity: 0, scale: 0.85 },
|
||||
show: { opacity: 1, scale: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle overlapping properties in motion objects', () => {
|
||||
const slideXMotion = slideX();
|
||||
const slideYMotion = slideY();
|
||||
const result = mix(slideXMotion, slideYMotion);
|
||||
expect(result).toEqual({
|
||||
hidden: { x: -10, y: -10 },
|
||||
show: { x: 0, y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single motion object', () => {
|
||||
const fadeMotion = fade();
|
||||
const result = mix(fadeMotion);
|
||||
expect(result).toEqual(fadeMotion);
|
||||
});
|
||||
|
||||
it('should handle empty motion objects', () => {
|
||||
const result = mix();
|
||||
expect(result).toEqual({ hidden: {}, show: {} });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
export { useSoundStore } from './sound';
|
||||
export { useLoadingStore } from './loading';
|
||||
export { useNoteStore } from './note';
|
||||
export { usePomodoroStore } from './pomodoro';
|
||||
export { usePresetStore } from './preset';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user