diff --git a/.pnp.cjs b/.pnp.cjs index 28590ab2..d4e9c7ed 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -29,6 +29,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./",\ "packageDependencies": [\ ["@base-ui/react", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:1.5.0"],\ + ["@emotion/is-prop-valid", "npm:1.4.0"],\ ["@eslint/eslintrc", "npm:3.3.5"],\ ["@eslint/js", "npm:9.39.4"],\ ["@qwik.dev/partytown", "npm:0.14.0"],\ @@ -49,23 +50,23 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-react", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:7.37.5"],\ ["eslint-plugin-react-hooks", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:7.1.1"],\ ["eslint-plugin-simple-import-sort", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:13.0.0"],\ + ["godlights", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:1.0.0"],\ ["husky", "npm:9.1.7"],\ ["hybus-genesis", "workspace:."],\ ["i18next", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:26.3.0"],\ ["i18next-browser-languagedetector", "npm:8.2.1"],\ ["i18next-http-backend", "npm:4.0.0"],\ + ["motion", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:12.43.0"],\ ["prettier", "npm:3.8.3"],\ ["react", "npm:19.2.6"],\ ["react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.6"],\ ["react-i18next", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:17.0.8"],\ - ["react-icons", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:5.6.0"],\ ["react-router-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:7.16.0"],\ ["react-simple-pull-to-refresh", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#patch:react-simple-pull-to-refresh@npm%3A1.3.4#~/.yarn/patches/react-simple-pull-to-refresh-npm-1.3.4-4e4715c092.patch::version=1.3.4&hash=b582cd"],\ ["react-snowfall", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#patch:react-snowfall@npm%3A2.4.0#~/.yarn/patches/react-snowfall-npm-2.4.0-3cb562b551.patch::version=2.4.0&hash=c12e9e"],\ ["react-spinners", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:0.17.0"],\ ["react-tiny-fab", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:4.0.4"],\ ["react-transition-group", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:4.4.5"],\ - ["recoil", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:0.7.7"],\ ["rolldown", "npm:1.0.3"],\ ["tailwindcss", "npm:4.3.0"],\ ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ @@ -2301,6 +2302,25 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@emotion/is-prop-valid", [\ + ["npm:1.4.0", {\ + "packageLocation": "./.yarn/cache/@emotion-is-prop-valid-npm-1.4.0-36d89399d2-6fbec4d5cd.zip/node_modules/@emotion/is-prop-valid/",\ + "packageDependencies": [\ + ["@emotion/is-prop-valid", "npm:1.4.0"],\ + ["@emotion/memoize", "npm:0.9.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@emotion/memoize", [\ + ["npm:0.9.0", {\ + "packageLocation": "./.yarn/cache/@emotion-memoize-npm-0.9.0-ccd80906b3-0381323593.zip/node_modules/@emotion/memoize/",\ + "packageDependencies": [\ + ["@emotion/memoize", "npm:0.9.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@eslint-community/eslint-utils", [\ ["npm:4.9.1", {\ "packageLocation": "./.yarn/cache/@eslint-community-eslint-utils-npm-4.9.1-30ad3d49de-863b546786.zip/node_modules/@eslint-community/eslint-utils/",\ @@ -6291,6 +6311,39 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["framer-motion", [\ + ["npm:12.43.0", {\ + "packageLocation": "./.yarn/cache/framer-motion-npm-12.43.0-b47c004d75-c8013cae9f.zip/node_modules/framer-motion/",\ + "packageDependencies": [\ + ["framer-motion", "npm:12.43.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:655929b8aa081425983c9c562fc0f8a4eae947fcaf75d2de96a053a466283dc283da1864e100418ef2549f0ac27903762e99bbd9711b2f7af45e9781def6b469#npm:12.43.0", {\ + "packageLocation": "./.yarn/__virtual__/framer-motion-virtual-f2ac57caea/0/cache/framer-motion-npm-12.43.0-b47c004d75-c8013cae9f.zip/node_modules/framer-motion/",\ + "packageDependencies": [\ + ["@emotion/is-prop-valid", "npm:1.4.0"],\ + ["@types/emotion__is-prop-valid", null],\ + ["@types/react", "npm:19.2.15"],\ + ["@types/react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.3"],\ + ["framer-motion", "virtual:655929b8aa081425983c9c562fc0f8a4eae947fcaf75d2de96a053a466283dc283da1864e100418ef2549f0ac27903762e99bbd9711b2f7af45e9781def6b469#npm:12.43.0"],\ + ["motion-dom", "npm:12.43.0"],\ + ["motion-utils", "npm:12.39.0"],\ + ["react", "npm:19.2.6"],\ + ["react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.6"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "packagePeers": [\ + "@emotion/is-prop-valid",\ + "@types/emotion__is-prop-valid",\ + "@types/react-dom",\ + "@types/react",\ + "react-dom",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["fs-extra", [\ ["npm:10.1.0", {\ "packageLocation": "./.yarn/cache/fs-extra-npm-10.1.0-86573680ed-05ce2c3b59.zip/node_modules/fs-extra/",\ @@ -6565,6 +6618,32 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["godlights", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/godlights-npm-1.0.0-bd5649270a-7450c01836.zip/node_modules/godlights/",\ + "packageDependencies": [\ + ["godlights", "npm:1.0.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:1.0.0", {\ + "packageLocation": "./.yarn/__virtual__/godlights-virtual-a517b2f171/0/cache/godlights-npm-1.0.0-bd5649270a-7450c01836.zip/node_modules/godlights/",\ + "packageDependencies": [\ + ["@types/react", "npm:19.2.15"],\ + ["@types/react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.3"],\ + ["godlights", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:1.0.0"],\ + ["react", "npm:19.2.6"],\ + ["react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.6"]\ + ],\ + "packagePeers": [\ + "@types/react-dom",\ + "@types/react",\ + "react-dom",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["gopd", [\ ["npm:1.0.1", {\ "packageLocation": "./.yarn/cache/gopd-npm-1.0.1-10c1d0b534-5fbc7ad57b.zip/node_modules/gopd/",\ @@ -6591,15 +6670,6 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ - ["hamt_plus", [\ - ["npm:1.0.2", {\ - "packageLocation": "./.yarn/cache/hamt_plus-npm-1.0.2-67a52ee1df-3680a1820b.zip/node_modules/hamt_plus/",\ - "packageDependencies": [\ - ["hamt_plus", "npm:1.0.2"]\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["has-bigints", [\ ["npm:1.0.2", {\ "packageLocation": "./.yarn/cache/has-bigints-npm-1.0.2-52732e614d-4e0426c900.zip/node_modules/has-bigints/",\ @@ -6810,6 +6880,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./",\ "packageDependencies": [\ ["@base-ui/react", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:1.5.0"],\ + ["@emotion/is-prop-valid", "npm:1.4.0"],\ ["@eslint/eslintrc", "npm:3.3.5"],\ ["@eslint/js", "npm:9.39.4"],\ ["@qwik.dev/partytown", "npm:0.14.0"],\ @@ -6830,23 +6901,23 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-react", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:7.37.5"],\ ["eslint-plugin-react-hooks", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:7.1.1"],\ ["eslint-plugin-simple-import-sort", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:13.0.0"],\ + ["godlights", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:1.0.0"],\ ["husky", "npm:9.1.7"],\ ["hybus-genesis", "workspace:."],\ ["i18next", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:26.3.0"],\ ["i18next-browser-languagedetector", "npm:8.2.1"],\ ["i18next-http-backend", "npm:4.0.0"],\ + ["motion", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:12.43.0"],\ ["prettier", "npm:3.8.3"],\ ["react", "npm:19.2.6"],\ ["react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.6"],\ ["react-i18next", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:17.0.8"],\ - ["react-icons", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:5.6.0"],\ ["react-router-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:7.16.0"],\ ["react-simple-pull-to-refresh", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#patch:react-simple-pull-to-refresh@npm%3A1.3.4#~/.yarn/patches/react-simple-pull-to-refresh-npm-1.3.4-4e4715c092.patch::version=1.3.4&hash=b582cd"],\ ["react-snowfall", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#patch:react-snowfall@npm%3A2.4.0#~/.yarn/patches/react-snowfall-npm-2.4.0-3cb562b551.patch::version=2.4.0&hash=c12e9e"],\ ["react-spinners", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:0.17.0"],\ ["react-tiny-fab", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:4.0.4"],\ ["react-transition-group", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:4.4.5"],\ - ["recoil", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:0.7.7"],\ ["rolldown", "npm:1.0.3"],\ ["tailwindcss", "npm:4.3.0"],\ ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ @@ -8135,6 +8206,57 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["motion", [\ + ["npm:12.43.0", {\ + "packageLocation": "./.yarn/cache/motion-npm-12.43.0-9cd05b659d-3c37060bd3.zip/node_modules/motion/",\ + "packageDependencies": [\ + ["motion", "npm:12.43.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:12.43.0", {\ + "packageLocation": "./.yarn/__virtual__/motion-virtual-655929b8aa/0/cache/motion-npm-12.43.0-9cd05b659d-3c37060bd3.zip/node_modules/motion/",\ + "packageDependencies": [\ + ["@emotion/is-prop-valid", "npm:1.4.0"],\ + ["@types/emotion__is-prop-valid", null],\ + ["@types/react", "npm:19.2.15"],\ + ["@types/react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.3"],\ + ["framer-motion", "virtual:655929b8aa081425983c9c562fc0f8a4eae947fcaf75d2de96a053a466283dc283da1864e100418ef2549f0ac27903762e99bbd9711b2f7af45e9781def6b469#npm:12.43.0"],\ + ["motion", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:12.43.0"],\ + ["react", "npm:19.2.6"],\ + ["react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.6"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "packagePeers": [\ + "@emotion/is-prop-valid",\ + "@types/emotion__is-prop-valid",\ + "@types/react-dom",\ + "@types/react",\ + "react-dom",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["motion-dom", [\ + ["npm:12.43.0", {\ + "packageLocation": "./.yarn/cache/motion-dom-npm-12.43.0-5ed81b11fa-ac5f7164b7.zip/node_modules/motion-dom/",\ + "packageDependencies": [\ + ["motion-dom", "npm:12.43.0"],\ + ["motion-utils", "npm:12.39.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["motion-utils", [\ + ["npm:12.39.0", {\ + "packageLocation": "./.yarn/cache/motion-utils-npm-12.39.0-59d768d874-5aa2972bf3.zip/node_modules/motion-utils/",\ + "packageDependencies": [\ + ["motion-utils", "npm:12.39.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["ms", [\ ["npm:2.1.2", {\ "packageLocation": "./.yarn/cache/ms-npm-2.1.2-ec0c1512ff-673cdb2c31.zip/node_modules/ms/",\ @@ -8714,28 +8836,6 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ - ["react-icons", [\ - ["npm:5.6.0", {\ - "packageLocation": "./.yarn/cache/react-icons-npm-5.6.0-797edf502d-571ac91977.zip/node_modules/react-icons/",\ - "packageDependencies": [\ - ["react-icons", "npm:5.6.0"]\ - ],\ - "linkType": "SOFT"\ - }],\ - ["virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:5.6.0", {\ - "packageLocation": "./.yarn/__virtual__/react-icons-virtual-7d8c53db05/0/cache/react-icons-npm-5.6.0-797edf502d-571ac91977.zip/node_modules/react-icons/",\ - "packageDependencies": [\ - ["@types/react", "npm:19.2.15"],\ - ["react", "npm:19.2.6"],\ - ["react-icons", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:5.6.0"]\ - ],\ - "packagePeers": [\ - "@types/react",\ - "react"\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["react-is", [\ ["npm:16.13.1", {\ "packageLocation": "./.yarn/cache/react-is-npm-16.13.1-a9b9382b4f-5aa564a1cd.zip/node_modules/react-is/",\ @@ -8931,37 +9031,6 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ - ["recoil", [\ - ["npm:0.7.7", {\ - "packageLocation": "./.yarn/cache/recoil-npm-0.7.7-4452f58b67-4ac9dfeddd.zip/node_modules/recoil/",\ - "packageDependencies": [\ - ["recoil", "npm:0.7.7"]\ - ],\ - "linkType": "SOFT"\ - }],\ - ["virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:0.7.7", {\ - "packageLocation": "./.yarn/__virtual__/recoil-virtual-9b5f644303/0/cache/recoil-npm-0.7.7-4452f58b67-4ac9dfeddd.zip/node_modules/recoil/",\ - "packageDependencies": [\ - ["@types/react", "npm:19.2.15"],\ - ["@types/react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.3"],\ - ["@types/react-native", null],\ - ["hamt_plus", "npm:1.0.2"],\ - ["react", "npm:19.2.6"],\ - ["react-dom", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:19.2.6"],\ - ["react-native", null],\ - ["recoil", "virtual:a0dcacbc896eccf189caadb21dc337a943d120637d608a889ce480f30d60747b2e369188c5cb540e65c153429bc715bc64aac1ec96b7e01c4ef9b6515ffe1d10#npm:0.7.7"]\ - ],\ - "packagePeers": [\ - "@types/react-dom",\ - "@types/react-native",\ - "@types/react",\ - "react-dom",\ - "react-native",\ - "react"\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["reflect.getprototypeof", [\ ["npm:1.0.10", {\ "packageLocation": "./.yarn/cache/reflect.getprototypeof-npm-1.0.10-8c3ce862a2-80a4e2be71.zip/node_modules/reflect.getprototypeof/",\ diff --git a/.yarn/cache/@emotion-is-prop-valid-npm-1.4.0-36d89399d2-6fbec4d5cd.zip b/.yarn/cache/@emotion-is-prop-valid-npm-1.4.0-36d89399d2-6fbec4d5cd.zip new file mode 100644 index 00000000..6a58316a Binary files /dev/null and b/.yarn/cache/@emotion-is-prop-valid-npm-1.4.0-36d89399d2-6fbec4d5cd.zip differ diff --git a/.yarn/cache/@emotion-memoize-npm-0.9.0-ccd80906b3-0381323593.zip b/.yarn/cache/@emotion-memoize-npm-0.9.0-ccd80906b3-0381323593.zip new file mode 100644 index 00000000..f3f63843 Binary files /dev/null and b/.yarn/cache/@emotion-memoize-npm-0.9.0-ccd80906b3-0381323593.zip differ diff --git a/.yarn/cache/framer-motion-npm-12.43.0-b47c004d75-c8013cae9f.zip b/.yarn/cache/framer-motion-npm-12.43.0-b47c004d75-c8013cae9f.zip new file mode 100644 index 00000000..a6fa18bb Binary files /dev/null and b/.yarn/cache/framer-motion-npm-12.43.0-b47c004d75-c8013cae9f.zip differ diff --git a/.yarn/cache/godlights-npm-1.0.0-bd5649270a-7450c01836.zip b/.yarn/cache/godlights-npm-1.0.0-bd5649270a-7450c01836.zip new file mode 100644 index 00000000..a128d525 Binary files /dev/null and b/.yarn/cache/godlights-npm-1.0.0-bd5649270a-7450c01836.zip differ diff --git a/.yarn/cache/hamt_plus-npm-1.0.2-67a52ee1df-3680a1820b.zip b/.yarn/cache/hamt_plus-npm-1.0.2-67a52ee1df-3680a1820b.zip deleted file mode 100644 index 757bb228..00000000 Binary files a/.yarn/cache/hamt_plus-npm-1.0.2-67a52ee1df-3680a1820b.zip and /dev/null differ diff --git a/.yarn/cache/motion-dom-npm-12.43.0-5ed81b11fa-ac5f7164b7.zip b/.yarn/cache/motion-dom-npm-12.43.0-5ed81b11fa-ac5f7164b7.zip new file mode 100644 index 00000000..cd233193 Binary files /dev/null and b/.yarn/cache/motion-dom-npm-12.43.0-5ed81b11fa-ac5f7164b7.zip differ diff --git a/.yarn/cache/motion-npm-12.43.0-9cd05b659d-3c37060bd3.zip b/.yarn/cache/motion-npm-12.43.0-9cd05b659d-3c37060bd3.zip new file mode 100644 index 00000000..0c0aa28f Binary files /dev/null and b/.yarn/cache/motion-npm-12.43.0-9cd05b659d-3c37060bd3.zip differ diff --git a/.yarn/cache/motion-utils-npm-12.39.0-59d768d874-5aa2972bf3.zip b/.yarn/cache/motion-utils-npm-12.39.0-59d768d874-5aa2972bf3.zip new file mode 100644 index 00000000..29d32a26 Binary files /dev/null and b/.yarn/cache/motion-utils-npm-12.39.0-59d768d874-5aa2972bf3.zip differ diff --git a/.yarn/cache/react-icons-npm-5.6.0-797edf502d-571ac91977.zip b/.yarn/cache/react-icons-npm-5.6.0-797edf502d-571ac91977.zip deleted file mode 100644 index 93d97a04..00000000 Binary files a/.yarn/cache/react-icons-npm-5.6.0-797edf502d-571ac91977.zip and /dev/null differ diff --git a/.yarn/cache/recoil-npm-0.7.7-4452f58b67-4ac9dfeddd.zip b/.yarn/cache/recoil-npm-0.7.7-4452f58b67-4ac9dfeddd.zip deleted file mode 100644 index 29a25e4e..00000000 Binary files a/.yarn/cache/recoil-npm-0.7.7-4452f58b67-4ac9dfeddd.zip and /dev/null differ diff --git a/eslint.config.mjs b/eslint.config.mjs index 2a032576..e67ede24 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -54,17 +54,20 @@ export default [ 'react/jsx-uses-react': 'off', 'react/jsx-uses-vars': 'error', 'react/prop-types': 0, - camelcase: [ - 'error', - { properties: 'always', ignoreDestructuring: true }, - ], + camelcase: ['error', { properties: 'always', ignoreDestructuring: true }], 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': [ - 'warn', + 'react-hooks/exhaustive-deps': 'warn', + }, + }, + { + files: ['src/app/components/seasonal/SummerLensFlareEffect.tsx'], + rules: { + 'react/no-unknown-property': [ + 'error', { - additionalHooks: 'useRecoilCallback', + ignore: ['dispose', 'object'], }, ], }, diff --git a/index.html b/index.html index 7b82e6d8..1c900235 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,7 @@ + 버스하냥 - 한양대 ERICA 셔틀버스 시간표 서비스 @@ -77,25 +78,39 @@ height="0" width="0" style="display:none;visibility:hidden">
diff --git a/package.json b/package.json index ab516ade..b66f2ff3 100644 --- a/package.json +++ b/package.json @@ -13,26 +13,27 @@ }, "dependencies": { "@base-ui/react": "^1.5.0", + "@emotion/is-prop-valid": "1.4.0", "@qwik.dev/partytown": "^0.14.0", "@tanstack/react-query": "^5.100.14", "@tw-classed/react": "^2.0.0", "@types/node": "^22.19.19", "axios": "^1.16.1", "dayjs": "^1.11.21", + "godlights": "1.0.0", "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.0", + "motion": "12.43.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-i18next": "^17.0.8", - "react-icons": "^5.6.0", "react-router-dom": "^7.16.0", "react-simple-pull-to-refresh": "patch:react-simple-pull-to-refresh@npm%3A1.3.4#~/.yarn/patches/react-simple-pull-to-refresh-npm-1.3.4-4e4715c092.patch", "react-snowfall": "patch:react-snowfall@npm%3A2.4.0#~/.yarn/patches/react-snowfall-npm-2.4.0-3cb562b551.patch", "react-spinners": "^0.17.0", "react-tiny-fab": "^4.0.4", "react-transition-group": "^4.4.5", - "recoil": "^0.7.7", "vite-plugin-svgr": "^5.2.0" }, "devDependencies": { diff --git a/public/image/apple-touch-icon-120x120.png b/public/image/apple-touch-icon-120x120.png deleted file mode 100644 index 624e826c..00000000 Binary files a/public/image/apple-touch-icon-120x120.png and /dev/null differ diff --git a/public/image/apple-touch-icon-152x152.png b/public/image/apple-touch-icon-152x152.png deleted file mode 100644 index 26eb71bb..00000000 Binary files a/public/image/apple-touch-icon-152x152.png and /dev/null differ diff --git a/public/image/apple-touch-icon-180x180.png b/public/image/apple-touch-icon-180x180.png deleted file mode 100644 index 69185777..00000000 Binary files a/public/image/apple-touch-icon-180x180.png and /dev/null differ diff --git a/public/image/apple-touch-icon-60x60.png b/public/image/apple-touch-icon-60x60.png deleted file mode 100644 index 101b521b..00000000 Binary files a/public/image/apple-touch-icon-60x60.png and /dev/null differ diff --git a/public/image/apple-touch-icon-76x76.png b/public/image/apple-touch-icon-76x76.png deleted file mode 100644 index fa3d3151..00000000 Binary files a/public/image/apple-touch-icon-76x76.png and /dev/null differ diff --git a/public/image/autumn_brown.svg b/public/image/autumn_brown.svg new file mode 100644 index 00000000..8c207970 --- /dev/null +++ b/public/image/autumn_brown.svg @@ -0,0 +1,15 @@ + + + + + + + diff --git a/public/image/autumn_orange.svg b/public/image/autumn_orange.svg new file mode 100644 index 00000000..f1c072cc --- /dev/null +++ b/public/image/autumn_orange.svg @@ -0,0 +1,15 @@ + + + + + + + diff --git a/public/image/christmas_mode_black_48dp.svg b/public/image/christmas_mode_black_48dp.svg deleted file mode 100644 index 55080f1b..00000000 --- a/public/image/christmas_mode_black_48dp.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/public/image/github_light.png b/public/image/github_light.png deleted file mode 100644 index f8c5c14b..00000000 Binary files a/public/image/github_light.png and /dev/null differ diff --git a/public/image/og-image.png b/public/image/og-image.png deleted file mode 100644 index 36da589f..00000000 Binary files a/public/image/og-image.png and /dev/null differ diff --git a/public/image/selected.svg b/public/image/selected.svg deleted file mode 100644 index 43cd634c..00000000 --- a/public/image/selected.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/snowflake.svg b/public/image/snowflake.svg deleted file mode 100644 index 82c1f3e1..00000000 --- a/public/image/snowflake.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index fd9b7ab1..af64538e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,9 +8,16 @@ import { Transition } from 'react-transition-group' import Arrow from '/public/image/expand_less_white_48dp.svg?react' import HelpImg from '/public/image/helpblack.svg?react' import { Shuttle } from '@/components' +import ThemeDebugMenu from '@/components/debug/ThemeDebugMenu' import Fabs from '@/components/fab/fab' import { useDarkMode } from '@/components/useDarkMode' -import { useDarkmodeContext } from '@/context/ThemeContext' +import { + isSeasonalTheme, + normalizeTheme, + SEASONAL_THEME_ENABLED_STORAGE_KEY, + THEME, + useDarkmodeContext, +} from '@/context/ThemeContext' import { StopLocation } from '@/data' import Refreshing from './app/components/ptr/refreshing-content' @@ -29,6 +36,8 @@ type MainCardHeight = | 'stationPromptRealtime' | 'defaultPrompt' +const CURRENT_YEAR = new Date().getFullYear() + const cardShellBase = 'mb-3 justify-center items-center font-medium rounded-lg transition-colors text-theme-text border-theme-border shadow-theme-shadow' const buttonShellBase = @@ -39,12 +48,12 @@ const heightTransitionCardBase = const buttonBase = 'flex will-change-transform overflow-hidden cursor-default border-none px-2 py-6 hm:py-4 hm:text-sm hm:leading-4' const circleBase = - "flex rounded-full inline-block transition-transform h-3 w-3 rt1:h-2.5 rt1:w-2.5 hsm:my-1" + "relative flex rounded-full inline-block shrink-0 transition-transform h-3 w-3 rt1:h-2.5 rt1:w-2.5 hsm:my-1" const circleThemeVariants = { variants: { 'data-theme': { spring: - "rotate-45 scale-75 rounded-none before:absolute before:w-full before:h-full before:rounded-full before:bg-inherit before:content-[''] before:left-[-50%] after:absolute after:w-full after:h-full after:rounded-full after:bg-inherit after:content-[''] after:top-[-50%]", + "rotate-45 scale-75 rounded-none before:absolute before:left-[-50%] before:top-0 before:w-full before:h-full before:rounded-full before:bg-inherit before:content-[''] after:absolute after:left-0 after:top-[-50%] after:w-full after:h-full after:rounded-full after:bg-inherit after:content-['']", default: '', }, }, @@ -60,7 +69,9 @@ const themeRootVariants = { dark: 'dark', christmas: 'christmas', spring: 'spring', - frozen: 'frozen', + summer: 'summer', + autumn: 'autumn', + winter: 'winter', }, }, defaultVariants: { @@ -118,6 +129,7 @@ const Button = classed('div', `${buttonShellBase} ${buttonBase}`, { }) const FulltimeButton = classed('div', `${cardBase} ${buttonBase} w-full cursor-default`) const HeadlineWrapper = classed('div', 'relative drag-save-n') +const TitleRow = classed('div', 'grid grid-cols-[1fr_auto_1fr] items-center') const HelpIcon = classed( HelpImg, 'bottom-3 right-0 absolute h-9 w-9 hsm:h-8 hsm:w-8 cursor-default drag-save-n', @@ -230,25 +242,39 @@ function App() { const [modalAni, setModalAni] = useState(false) const [noticeContent, setNoticeContent] = useState('') const [noticeTitle, setNoticeTitle] = useState('') - const { toggleTheme } = useDarkMode() + const { + theme, + automaticSeasonTheme, + manualSeasonalTheme, + seasonalThemeEnabled, + } = useDarkmodeContext() + const { + setAutomaticTheme, + setBackground, + setManualSeasonalThemeMode, + setSeasonalThemeEnabledMode, + setThemeMode, + toggleTheme, + } = useDarkMode() const [touchPrompt, setTouchPrompt] = useState( window.localStorage.getItem('touch_info') === null, ) - {/** 테마 Alert state */} - const [themeAlert, setThemeAlert] = useState( - // window.localStorage.getItem('xmas_alert') === null, - // window.localStorage.getItem('spring_2025') === null, - window.localStorage.getItem('frozen_2025') === null, - ) const [routeCardClick, setRouteCardClick] = useState(false) const routeCardRef = useRef(null) + const seasonalChoiceHandledRef = useRef(false) + const modalDismissTimeoutRef = useRef(null) const handleContextMenu = (e: { preventDefault: () => void }) => { e.preventDefault() } const openModal = () => { + if (modalDismissTimeoutRef.current !== null) { + window.clearTimeout(modalDismissTimeoutRef.current) + modalDismissTimeoutRef.current = null + } + setModalAni(false) setModalOpen(true) } @@ -264,14 +290,32 @@ function App() { openModal() } - const closeModal = () => { + const dismissModal = () => { + if (modalDismissTimeoutRef.current !== null) return setModalAni(true) - setTimeout(() => { + modalDismissTimeoutRef.current = window.setTimeout(() => { + modalDismissTimeoutRef.current = null setModalAni(false) setModalOpen(false) }, 300) } + const closeModal = () => { + if (modalTarget === 'Seasonal') { + if (seasonalChoiceHandledRef.current) return + seasonalChoiceHandledRef.current = true + setSeasonalThemeEnabledMode(false) + } + dismissModal() + } + + const handleEnableSeasonalTheme = () => { + if (seasonalChoiceHandledRef.current) return + seasonalChoiceHandledRef.current = true + setAutomaticTheme() + dismissModal() + } + const handleRefresh = (): Promise => { return new Promise(() => { location.reload() @@ -280,7 +324,6 @@ function App() { const { t, i18n } = useTranslation() - const { theme } = useDarkmodeContext() const [tab, setTab] = useState('') const [realtimeMode, setRealtimeMode] = useState(false) @@ -348,61 +391,56 @@ function App() { }, []) useEffect(() => { - const localTheme = window.localStorage.getItem('theme') || 'light' - if (localTheme === 'dark') { - document.body.classList.remove('light') - document.body.classList.remove('christmas') - document.body.classList.remove('spring') - document.body.classList.remove('frozen') - document.body.classList.add('dark') - } else if (localTheme === 'christmas' || localTheme === 'spring') { - // 강제 light 적용 - document.body.classList.add('light') - document.body.classList.remove('spring') - document.body.classList.remove('christmas') - document.body.classList.remove('dark') - toggleTheme() - {/** 봄 테마 사용 시 - } else if (localTheme === 'spring') { - document.body.classList.remove('light') - document.body.classList.remove('dark') - document.body.classList.remove('christmas') - document.body.classList.remove('frozen') - document.body.classList.add('spring') - */} - } else if (localTheme === 'frozen') { - document.body.classList.remove('light') - document.body.classList.remove('dark') - document.body.classList.remove('christmas') - document.body.classList.remove('spring') - document.body.classList.add('frozen') - } else { - document.body.classList.remove('dark') - document.body.classList.remove('christmas') - document.body.classList.remove('spring') - document.body.classList.remove('frozen') - document.body.classList.add('light') + return () => { + if (modalDismissTimeoutRef.current !== null) { + window.clearTimeout(modalDismissTimeoutRef.current) + modalDismissTimeoutRef.current = null + } } - }, [theme, toggleTheme]) + }, []) useEffect(() => { - const status = window.localStorage.getItem('touch_info') === null - setTouchPrompt(status) - }, []) + setBackground() + }, [setBackground, theme]) + + useEffect(() => { + const automaticThemeIsVisible = + seasonalThemeEnabled && + manualSeasonalTheme === null && + theme !== THEME.DARK + + if (automaticThemeIsVisible && theme !== automaticSeasonTheme) { + setAutomaticTheme() + } + }, [ + automaticSeasonTheme, + manualSeasonalTheme, + seasonalThemeEnabled, + setAutomaticTheme, + theme, + ]) - {/** 테마 사용시 최초 Alert */} useEffect(() => { - const status = window.localStorage.getItem('frozen_2025') === null - setThemeAlert(status) + const status = window.localStorage.getItem('touch_info') === null + setTouchPrompt(status) }, []) + {/** 계절 테마를 아직 선택하지 않은 사용자의 최초 선택 */} useEffect(() => { - if (themeAlert) { - setModalTarget('Frozen') - openModal() - window.localStorage.setItem('frozen_2025', 'false') + const storedTheme = normalizeTheme(window.localStorage.getItem('theme')) + const storedSeasonalPreference = window.localStorage.getItem( + SEASONAL_THEME_ENABLED_STORAGE_KEY, + ) + const hasStoredSeasonalPreference = + storedSeasonalPreference === 'true' || + storedSeasonalPreference === 'false' + + if (!hasStoredSeasonalPreference && !isSeasonalTheme(storedTheme)) { + seasonalChoiceHandledRef.current = false + setModalTarget('Seasonal') + setModalOpen(true) } - }, [themeAlert]) + }, []) return ( <> @@ -428,16 +466,28 @@ function App() {
- - {t('title')} - <HelpIcon - aria-label="information icon" - onClick={handleModalTarget} - onContextMenu={handleContextMenu} - //draggable="false" - fill="var(--color-theme-text)" - ></HelpIcon> - + + + }> @@ -635,7 +685,7 @@ function App() { {t('all_btn')} - Copyright © 2020-2025{' '} + Copyright © 2020-{CURRENT_YEAR}{' '} diff --git a/src/app/components/debug/ThemeDebugMenu.tsx b/src/app/components/debug/ThemeDebugMenu.tsx new file mode 100644 index 00000000..7e3112be --- /dev/null +++ b/src/app/components/debug/ThemeDebugMenu.tsx @@ -0,0 +1,200 @@ +import { classed } from '@tw-classed/react' +import React, { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { isSeasonalTheme, THEME } from '@/context/ThemeContext' + +const ThemeDebugRoot = classed( + 'div', + 'relative z-40 ml-2 inline-flex justify-self-start font-normal', +) +const ThemeDebugTrigger = classed( + 'button', + 'h-7 whitespace-nowrap rounded-md border border-solid border-gray-300 bg-theme-card px-2 text-[11px] font-medium text-theme-text shadow-sm hsm:h-6 hsm:px-1.5 hsm:text-[10px]', +) +const ThemeDebugPanel = classed( + 'div', + 'absolute right-0 top-full mt-2 w-64 max-w-[calc(100vw-2.5rem)] rounded-xl border border-solid border-gray-200 bg-theme-card p-3 text-theme-text shadow-[0_0.75rem_2rem_rgba(15,23,42,0.22)] hsm:-right-4', +) +const ThemeDebugHeader = classed( + 'div', + 'mb-3 flex items-start justify-between gap-3 text-left', +) +const ThemeDebugTitle = classed('p', 'text-sm font-bold leading-5') +const ThemeDebugDescription = classed( + 'p', + 'text-[10px] leading-4 opacity-70', +) +const ThemeDebugClose = classed( + 'button', + 'flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-solid border-gray-300 bg-theme-main text-sm leading-none text-theme-text', +) +const ThemeDebugGrid = classed( + 'div', + 'grid grid-cols-2 gap-2', +) +const ThemeDebugOption = classed( + 'button', + 'flex h-10 items-center justify-start gap-2 whitespace-nowrap rounded-lg border border-solid border-gray-200 bg-theme-main px-3 text-xs font-medium text-theme-text aria-pressed:border-gray-500 aria-pressed:font-bold aria-pressed:shadow-[inset_0_0_0_1px_currentColor]', +) +const ThemeSwatch = classed( + 'span', + 'block h-5 w-5 shrink-0 rounded-full border border-solid border-gray-300 shadow-sm', +) +const SelectedMark = classed('span', 'ml-auto text-xs font-bold') + +interface ThemeDebugMenuProps { + theme: THEME + manualSeasonalTheme: THEME | null + seasonalThemeEnabled: boolean + onSelectTheme: (theme: THEME) => void + onSelectManualTheme: (theme: THEME) => void + onSelectAutomaticTheme: () => void + onToggleTheme: () => void +} + +const ThemeDebugMenu = ({ + theme, + manualSeasonalTheme, + seasonalThemeEnabled, + onSelectTheme, + onSelectManualTheme, + onSelectAutomaticTheme, + onToggleTheme, +}: ThemeDebugMenuProps) => { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handlePointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false) + } + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsOpen(false) + } + + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen]) + + const handleThemeSelect = (themeName: THEME) => { + if (themeName === THEME.DARK) { + if (theme !== THEME.DARK) onToggleTheme() + } else if (themeName === THEME.LIGHT && theme === THEME.DARK) { + onToggleTheme() + } else if (isSeasonalTheme(themeName)) { + onSelectManualTheme(themeName) + } else { + onSelectTheme(themeName) + } + setIsOpen(false) + } + const handleAutomaticThemeSelect = () => { + onSelectAutomaticTheme() + setIsOpen(false) + } + + const themeActions: Array<{ + theme: THEME + text: string + swatch: string + }> = [ + { theme: THEME.LIGHT, text: t('light'), swatch: '#ffffff' }, + { theme: THEME.DARK, text: t('dark'), swatch: '#374151' }, + { theme: THEME.SPRING, text: t('theme_spring'), swatch: '#e37da6' }, + { theme: THEME.SUMMER, text: t('theme_summer'), swatch: '#2ca6a4' }, + { theme: THEME.AUTUMN, text: t('theme_autumn'), swatch: '#b45309' }, + { theme: THEME.WINTER, text: t('theme_winter'), swatch: '#647ab3' }, + { + theme: THEME.CHRISTMAS, + text: t('theme_christmas'), + swatch: + 'linear-gradient(135deg, #b23e3e 0%, #b23e3e 50%, #3e5f4b 50%, #3e5f4b 100%)', + }, + ] + const automaticThemeSelected = + seasonalThemeEnabled && manualSeasonalTheme === null + + return ( + + setIsOpen((open) => !open)} + > + 테마 설정 + + {isOpen && ( + + +
+ 테마 미리보기 + + 개발 모드에서만 표시되는 선택 메뉴입니다. + +
+ setIsOpen(false)} + > + × + +
+ + + + {t('theme_auto')} + {automaticThemeSelected && ( + + )} + + {themeActions.map((themeAction) => { + const isSelected = isSeasonalTheme(themeAction.theme) + ? seasonalThemeEnabled && + manualSeasonalTheme === themeAction.theme + : theme === themeAction.theme + + return ( + handleThemeSelect(themeAction.theme)} + > + + {themeAction.text} + {isSelected && } + + ) + })} + +
+ )} +
+ ) +} + +export default ThemeDebugMenu diff --git a/src/app/components/fab/fab.tsx b/src/app/components/fab/fab.tsx index 546373e7..2e875077 100644 --- a/src/app/components/fab/fab.tsx +++ b/src/app/components/fab/fab.tsx @@ -6,16 +6,14 @@ import React, { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Action, Fab } from 'react-tiny-fab' -//import ChristmasImg from '/image/christmas_mode_black_48dp.svg' import DarkImg from '/image/dark_mode_black_48dp.svg' import Email from '/image/email_black_48dp.svg' import Arrow from '/image/expand_less_white_48dp.svg' -//import SpringImg from '/image/flower.svg' +import ThemeImg from '/image/flower.svg' import Info from '/image/infoblack.svg' import LangImg from '/image/lang_black_48dp.svg' import LightImg from '/image/light_mode_black_48dp.svg' import Donate from '/image/local_cafe_black_48dp.svg' -import SnowflakeImg from '/image/snowflake.svg' import { useDarkmodeContext } from '@/context/ThemeContext' import { useDarkMode } from '../useDarkMode' @@ -42,12 +40,15 @@ const FabBackground = classed('div', 'select-none font-Ptd', { 'data-state': 'closed', }, }) - const Fabs = (props: { openModal: () => void mTarget: React.Dispatch> }) => { - const { toggleTheme } = useDarkMode() + const { + toggleTheme, + seasonalThemeEnabled, + toggleSeasonalTheme, + } = useDarkMode() const { t, i18n } = useTranslation() const [isOpen, setIsOpen] = useState(false) const { theme } = useDarkmodeContext() @@ -123,6 +124,11 @@ const Fabs = (props: { toggleTheme() }) } + const handleSeasonalThemeOnClick = (): Promise => { + return new Promise(() => { + toggleSeasonalTheme() + }) + } const handleLangOnClick = (): Promise => { return new Promise(() => { if (i18n.language === 'en') { @@ -139,40 +145,50 @@ const Fabs = (props: { if (theme === 'dark') { setMetadata({ changeColor: '#374151', - //changeText: t('light'), - //imgIcon: LightImg, + changeText: t('light'), + imgIcon: LightImg, iconColor: 'white', dataTheme: 'dark', - // changeText: t('christmas'), - // imgIcon: ChristmasImg, - // changeText: t('spring'), - // imgIcon: SpringImg, - changeText: t('frozen'), - imgIcon: SnowflakeImg, }) } else if (theme === 'christmas') { setMetadata({ - changeText: t('light'), + changeText: t('dark'), changeColor: 'var(--color-theme-main)', iconColor: 'white', dataTheme: 'christmas', - imgIcon: LightImg, + imgIcon: DarkImg, }) } else if (theme === 'spring') { setMetadata({ - changeText: t('light'), + changeText: t('dark'), changeColor: '#e37da6', iconColor: 'white', dataTheme: 'spring', - imgIcon: LightImg, + imgIcon: DarkImg, }) - } else if (theme === 'frozen') { + } else if (theme === 'summer') { setMetadata({ - changeText: t('light'), + changeText: t('dark'), + changeColor: '#2ca6a4', + iconColor: 'white', + dataTheme: 'summer', + imgIcon: DarkImg, + }) + } else if (theme === 'autumn') { + setMetadata({ + changeText: t('dark'), + changeColor: '#b45309', + iconColor: 'white', + dataTheme: 'autumn', + imgIcon: DarkImg, + }) + } else if (theme === 'winter') { + setMetadata({ + changeText: t('dark'), changeColor: '#647ab3', iconColor: 'white', - dataTheme: 'frozen', - imgIcon: LightImg, + dataTheme: 'winter', + imgIcon: DarkImg, }) } else { setMetadata({ @@ -228,7 +244,9 @@ const Fabs = (props: { onClick={handleDarkOnClick} onContextMenu={handleContextMenu} > - + + + + seasonal theme icon e.preventDefault()} + /> + + - + - + - + - + {props.type === props.value ? ( diff --git a/src/app/components/index.ts b/src/app/components/index.ts index f65b9907..9c69c5cc 100644 --- a/src/app/components/index.ts +++ b/src/app/components/index.ts @@ -1,2 +1 @@ -export * from './fab/fab' export * from './shuttle/Shuttle' diff --git a/src/app/components/lang/lang.en.json b/src/app/components/lang/lang.en.json index 47853707..0d217537 100644 --- a/src/app/components/lang/lang.en.json +++ b/src/app/components/lang/lang.en.json @@ -1,11 +1,8 @@ { - "lang": "en", "changeLang": "한국어", "title": "BusHanyang", "notice": "Notice", - "news": "News", - "cycle": "Cycle", "direct": "Direct", "cycle_ja": "Direct", @@ -42,7 +39,6 @@ "yesulin_btn": "Yesulin Apartment", "jungang_btn": "Jungang Station", "all_btn": "Full Timetable", - "to_yesulin": "To Yesulin", "no_today": "There is no shuttle bus for today.", "end_today": "Shuttle bus service has ended today.", @@ -54,6 +50,14 @@ "changelog": "ChangeLog", "dark": "Dark", "light": "Light", + "theme_on": "Theme On", + "theme_off": "Theme Off", + "theme_auto": "Auto Season", + "theme_spring": "Spring", + "theme_summer": "Summer", + "theme_autumn": "Autumn", + "theme_winter": "Winter", + "theme_christmas": "Christmas", "info": "Information", "close": "Close", @@ -116,15 +120,9 @@ "last_train_img": "/image/last_train_en.svg", "rapid_train_img": "/image/rapid_train_en.svg", - "christmas": "Merry Christmas!", - "christmas_txt": "Discover the new Christmas theme now 🎄
You can always switch back from the floating menu :)

(This notification will only appear once.)", - "christmas_btn": "Switch Now 🎁", - - "spring": "Happy Spring!", - "spring_txt": "Discover the new spring theme now🌸
You can always switch back from the floating menu :)

(This notification will only appear once.)", - "spring_btn": "Coloring with flowers 🌸", - - "frozen": "Frozen Theme", - "frozen_txt": "Discover the new frozen theme now❄️
You can always switch back from the floating menu :)

(This notification will only appear once.)", - "frozen_btn": "Switch Now ❄️" + "seasonal_prompt_title": "Would you like to use seasonal themes?", + "seasonal_prompt_text": "The screen colors and effects such as rain and snow change with the season. You can turn seasonal themes on or off anytime from the bottom-right menu.", + "seasonal_prompt_once": "(This message is shown only once.)", + "seasonal_prompt_enable": "Enable seasonal themes", + "seasonal_prompt_keep": "Keep seasonal themes off" } diff --git a/src/app/components/lang/lang.ko.json b/src/app/components/lang/lang.ko.json index b9c79788..dacf74aa 100644 --- a/src/app/components/lang/lang.ko.json +++ b/src/app/components/lang/lang.ko.json @@ -1,11 +1,8 @@ { - "lang": "ko", "changeLang": "English", "title": "버스하냥", "notice": "공지", - "news": "소식", - "cycle": "순환", "direct": "직행", "cycle_ja": "중앙역", @@ -42,7 +39,6 @@ "yesulin_btn": "예술인", "jungang_btn": "중앙역", "all_btn": "전체 시간표", - "to_yesulin": "예술인행", "no_today": "오늘 운행하는 셔틀이 없습니다.", "end_today": "오늘 셔틀 운행이 종료되었습니다.", @@ -54,6 +50,14 @@ "changelog": "변경로그", "dark": "다크모드", "light": "라이트모드", + "theme_on": "테마 켜기", + "theme_off": "테마 끄기", + "theme_auto": "계절 자동", + "theme_spring": "봄", + "theme_summer": "여름", + "theme_autumn": "가을", + "theme_winter": "겨울", + "theme_christmas": "크리스마스", "info": "정보", "close": "닫기", @@ -116,15 +120,9 @@ "last_train_img": "/image/last_train_kr.svg", "rapid_train_img": "/image/rapid_train_kr.svg", - "christmas": "메리 크리스마스!", - "christmas_txt": "지금 크리스마스 테마를 만나보세요🎄
플로팅 메뉴에서 언제든 다시 바꿀 수 있어요 :)

(이 알림창은 최초 한번만 보여집니다.)", - "christmas_btn": "지금 적용하기 🎁", - - "spring": "봄이 왔어요!", - "spring_txt": "벚꽃 테마가 출시되었습니다🌸
플로팅 메뉴에서 언제든 다시 바꿀 수 있어요 :)

(이 알림창은 최초 한번만 보여집니다.)", - "spring_btn": "지금 적용하기 🌸", - - "frozen": "겨울 테마", - "frozen_txt": "겨울 한정 테마가 출시되었습니다❄️
플로팅 메뉴에서 언제든 다시 바꿀 수 있어요 :)

(이 알림창은 최초 한번만 보여집니다.)", - "frozen_btn": "지금 적용하기 ❄️" + "seasonal_prompt_title": "계절 테마를 사용해 볼까요?", + "seasonal_prompt_text": "계절에 맞춰 화면 색상과 비·눈 같은 효과가 함께 바뀝니다. 오른쪽 아래 메뉴에서 언제든 켜거나 끌 수 있어요.", + "seasonal_prompt_once": "(이 안내는 처음 한 번만 표시됩니다.)", + "seasonal_prompt_enable": "계절 테마 켜기", + "seasonal_prompt_keep": "계절 테마 사용 안 함" } diff --git a/src/app/components/modal/modal.tsx b/src/app/components/modal/modal.tsx index 250ce132..a3fceee2 100644 --- a/src/app/components/modal/modal.tsx +++ b/src/app/components/modal/modal.tsx @@ -1,5 +1,5 @@ import { classed } from '@tw-classed/react' -import React, { useRef } from 'react' +import React, { useLayoutEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useDarkmodeContext } from '@/context/ThemeContext' @@ -20,51 +20,44 @@ const ModalBackground = classed( }, ) -const ModalMain = classed( - 'div', - 'fixed inset-0 z-99 bg-black/60 select-none', - { - variants: { - 'data-state': { - open: 'flex items-center', - closed: 'hidden', - }, - 'data-ani': { - opening: 'animate-modalBgShow', - closing: 'animate-modalBgClose', - idle: '', - }, +const ModalMain = classed('div', 'fixed inset-0 z-99 bg-black/60 select-none', { + variants: { + 'data-state': { + open: 'flex items-center', + closed: 'hidden', }, - defaultVariants: { - 'data-state': 'closed', - 'data-ani': 'idle', + 'data-ani': { + opening: 'animate-modalBgShow', + closing: 'animate-modalBgClose', + idle: '', }, }, -) + defaultVariants: { + 'data-state': 'closed', + 'data-ani': 'idle', + }, +}) const ModalButton = classed('button', 'outline-hidden cursor-pointer border-0') -const ModalSection = classed( - 'section', - 'w-11/12 max-w-(--breakpoint-sm) mx-auto rounded-lg bg-white', - { - variants: { - 'data-ani': { - opening: 'animate-modalShow', - closing: 'animate-modalClose', - idle: '', - }, - 'data-content': { - notice: 'overflow-hidden', - default: 'overflow-auto', - }, +const ModalSection = classed('section', 'w-11/12 mx-auto rounded-lg bg-white', { + variants: { + 'data-ani': { + opening: 'animate-modalShow', + closing: 'animate-modalClose', + idle: '', }, - defaultVariants: { - 'data-ani': 'idle', - 'data-content': 'default', + 'data-content': { + notice: 'max-w-(--breakpoint-sm) overflow-hidden', + seasonal: 'max-w-(--breakpoint-sm) overflow-hidden', + default: 'max-w-(--breakpoint-sm) overflow-auto', }, }, -) + defaultVariants: { + 'data-ani': 'idle', + 'data-content': 'default', + }, +}) const ModalHeader = classed( 'header', @@ -117,6 +110,7 @@ export const Modal = (props: { close: () => void children: React.ReactNode mTarget: string + seasonalFooter?: React.ReactNode }) => { const modalBackgroundRef = useRef(null) const handleClickModalBackground = (e: React.MouseEvent) => { @@ -125,7 +119,7 @@ export const Modal = (props: { } } - const { theme } = useDarkmodeContext() + const { theme, setSeasonalThemePreview } = useDarkmodeContext() const { t } = useTranslation() const modalAnimationState = props.ani @@ -134,6 +128,17 @@ export const Modal = (props: { ? 'opening' : 'idle' + useLayoutEffect(() => { + const shouldPreviewSeasonalTheme = + props.open && props.mTarget === 'Seasonal' + + setSeasonalThemePreview(shouldPreviewSeasonalTheme) + + return () => { + setSeasonalThemePreview(false) + } + }, [props.mTarget, props.open, setSeasonalThemePreview]) + return ( {props.mTarget === 'Fabs' && ( @@ -159,19 +170,9 @@ export const Modal = (props: { {t('info')} )} - {props.mTarget === 'Christmas' && ( - - {t('christmas')} - - )} - {props.mTarget === 'Spring' && ( - - {t('spring')} - - )} - {props.mTarget === 'Frozen' && ( + {props.mTarget === 'Seasonal' && ( - {t('frozen')} + {t('seasonal_prompt_title')} )} {props.mTarget === 'Notice' && ( @@ -184,9 +185,13 @@ export const Modal = (props: { {props.children} - - {t('close')} - + {props.mTarget === 'Seasonal' ? ( + props.seasonalFooter + ) : ( + + {t('close')} + + )} ) : null} diff --git a/src/app/components/modal/modalOpen.tsx b/src/app/components/modal/modalOpen.tsx index cb322a29..4be44eda 100644 --- a/src/app/components/modal/modalOpen.tsx +++ b/src/app/components/modal/modalOpen.tsx @@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query' import { classed } from '@tw-classed/react' import { t } from 'i18next' import React from 'react' -import { Trans } from 'react-i18next' import { changelogAPI } from '@/network/changelog' @@ -13,20 +12,33 @@ const ChangelogMargin = classed('div', 'mb-[1em]') const ContentArea = classed('div', 'm-auto justify-between') const ChangelogDiv = classed(ContentArea, 'text-left') const ModalButton = classed('button', 'outline-hidden cursor-pointer border-0') -const ModalFooterButton = classed( +const ModalScrollArea = classed('div', 'font-Ptd overflow-auto max-h-[450px]') +const SeasonalPrompt = classed('div') +const SeasonalPromptText = classed( + 'p', + 'text-sm leading-6 opacity-90 hsm:text-[13px] hsm:leading-5', +) +const SeasonalPromptHint = classed( + 'p', + 'mt-4 text-sm leading-6 opacity-80 hsm:text-[13px] hsm:leading-5', +) +const SeasonalPromptPrimaryButton = classed( ModalButton, - 'mt-6 py-6 w-full text-white bg-indigo-400 font-Ptd font-bold text-lg rounded-md', + 'mt-6 w-full rounded-md bg-indigo-400 px-4 py-6 font-Ptd text-lg font-bold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 hsm:py-5 hsm:text-base', +) +const SeasonalPromptSecondaryButton = classed( + ModalButton, + 'rounded-md bg-gray-500 px-4 py-2 font-Ptd text-xs text-white transition-colors hover:bg-gray-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 hsm:min-h-11', ) -const ModalScrollArea = classed('div', 'font-Ptd overflow-auto max-h-[450px]') const ModalOpen = (props: { isOpen: boolean isModalAni: boolean - openModal: () => void closeModal: () => void mTarget: string noticeContent?: string noticeTitle?: string + onEnableSeasonalTheme: () => void }) => { const changelogs = useQuery({ queryKey: ['changelog'], @@ -34,11 +46,6 @@ const ModalOpen = (props: { staleTime: 5 * 60 * 1000, }) - const toggleTheme = (themeName: string) => { - window.localStorage.setItem('theme', themeName) - window.location.reload() - } - return ( + {t('seasonal_prompt_keep')} + + ) : undefined + } > @@ -72,35 +89,21 @@ const ModalOpen = (props: { src={t('info_link')} > )} - {props.mTarget === 'Christmas' && ( - <> - -
- - toggleTheme('christmas')}> - {t('christmas_btn')} - - - )} - {props.mTarget === 'Spring' && ( - <> - -
- - toggleTheme('spring')}> - {t('spring_btn')} - - - )} - {props.mTarget === 'Frozen' && ( - <> - -
- - toggleTheme('frozen')}> - {t('frozen_btn')} - - + {props.mTarget === 'Seasonal' && ( + + + {t('seasonal_prompt_text')} + + + {t('seasonal_prompt_once')} + + + {t('seasonal_prompt_enable')} + + )} {props.mTarget === 'Notice' && ( <> diff --git a/src/app/components/routemap/DotAnimation.tsx b/src/app/components/routemap/DotAnimation.tsx index fdbb16f2..0f12d057 100644 --- a/src/app/components/routemap/DotAnimation.tsx +++ b/src/app/components/routemap/DotAnimation.tsx @@ -1,7 +1,6 @@ import { classed } from '@tw-classed/react' -import React, { useEffect } from 'react' +import React from 'react' -import { useTimeTableContext } from '@/context/TimeTableContext' import { DotAnimationConfig, RouteAnimationFlag, @@ -132,47 +131,38 @@ export const getDotAnimationConfig = ( return [] } -export const useDotAnimation = (tab: string) => { - const [flagTable, setFlagTable] = React.useState({ - direct: [false, false, false, false, false, false], - cycle: [false, false, false, false, false, false], - yesulin: [false, false, false, false, false, false], - jungang: [false, false, false, false, false, false], - }) - const currTimetableArray = useTimeTableContext().currTimetable - const checkCurrTimetable = - React.useRef(undefined) - - React.useEffect(() => { - checkCurrTimetable.current = undefined - }, []) - - useEffect(() => { - if (checkCurrTimetable.current === currTimetableArray[0]) return - - checkCurrTimetable.current = currTimetableArray[0] +const createEmptyFlagTable = (): RouteAnimationFlag => ({ + direct: [false, false, false, false, false, false], + cycle: [false, false, false, false, false, false], + yesulin: [false, false, false, false, false, false], + jungang: [false, false, false, false, false, false], +}) - let tempFlagTable = { - direct: [false, false, false, false, false, false], - cycle: [false, false, false, false, false, false], - yesulin: [false, false, false, false, false, false], - jungang: [false, false, false, false, false, false], - } - - currTimetableArray.forEach((currTimetable: SingleShuttleSchedule) => { - const dotAnimationConfig = getDotAnimationConfig(tab, currTimetable) +export const buildDotAnimationFlagTable = ( + tab: string, + currTimetableArray: Array, +): RouteAnimationFlag => { + let flagTable = createEmptyFlagTable() - dotAnimationConfig.forEach((config) => { - tempFlagTable = applyDotAnimationFlag(config, tempFlagTable) - }) + currTimetableArray.forEach((currTimetable) => { + getDotAnimationConfig(tab, currTimetable).forEach((config) => { + flagTable = applyDotAnimationFlag(config, flagTable) }) - - setFlagTable(tempFlagTable) - }, [currTimetableArray, tab]) + }) return flagTable } +export const useDotAnimation = ( + tab: string, + currTimetableArray: Array, +) => { + return React.useMemo( + () => buildDotAnimationFlagTable(tab, currTimetableArray), + [currTimetableArray, tab], + ) +} + const DotAnimation = (props: { isOn: boolean index: number @@ -182,10 +172,7 @@ const DotAnimation = (props: { if (props.routeStatus === 'yesulin' && props.index === 2) return return ( - + ) } diff --git a/src/app/components/routemap/RouteMap.tsx b/src/app/components/routemap/RouteMap.tsx index aa2ff346..6429920a 100644 --- a/src/app/components/routemap/RouteMap.tsx +++ b/src/app/components/routemap/RouteMap.tsx @@ -2,12 +2,18 @@ import { classed } from '@tw-classed/react' import React from 'react' import { useTranslation } from 'react-i18next' +import { useDotAnimation } from '@/components/routemap/DotAnimation' import RouteVisual from '@/components/routemap/RouteVisual' +import { useShuttleTimetable } from '@/components/shuttle/useShuttleTimetable' +import { StopLocation } from '@/data' type TransitionStatus = 'entering' | 'entered' | 'exiting' | 'exited' | 'exit' const RouteRowsContainer = classed('div', 'grid grid-rows-5 gap-2') -const RouteColsContainer = classed('div', 'relative grid grid-cols-6 place-items-center') +const RouteColsContainer = classed( + 'div', + 'relative grid grid-cols-6 place-items-center', +) const RouteTextContainer = classed( 'div', 'whitespace-nowrap text-center tracking-tighter mt-1 hm:mt-2 hsm:mt-2 font-semibold', @@ -60,6 +66,8 @@ const MainContainer = classed( const RouteMap = (props: { status: string; tab: string }) => { const { t, i18n } = useTranslation() + const { routeTimetable } = useShuttleTimetable(props.tab as StopLocation) + const animationFlagTable = useDotAnimation(props.tab, routeTimetable) return ( @@ -86,19 +94,35 @@ const RouteMap = (props: { status: string; tab: string }) => { {t('direct')} - + {t('cycle')} - + {t('yesul')} - + {t('jung')} - + diff --git a/src/app/components/routemap/RouteVisual.tsx b/src/app/components/routemap/RouteVisual.tsx index 22c7b27a..0a927619 100644 --- a/src/app/components/routemap/RouteVisual.tsx +++ b/src/app/components/routemap/RouteVisual.tsx @@ -2,10 +2,7 @@ import { classed } from '@tw-classed/react' import React from 'react' import { useTranslation } from 'react-i18next' -import DotAnimation, { - DotColor, - useDotAnimation, -} from '@/components/routemap/DotAnimation' +import DotAnimation, { DotColor } from '@/components/routemap/DotAnimation' import { RouteAnimationFlag } from '@/data' type RouteStatus = keyof RouteAnimationFlag @@ -34,7 +31,7 @@ const RouteLine = classed( yesulin: 'bg-chip-green', jungang: 'bg-chip-purple', orange: 'bg-chip-orange', - inactive: 'bg-zinc-200 dark:bg-slate-500', + inactive: 'bg-[var(--color-route-inactive)] dark:bg-slate-500', }, }, defaultVariants: { @@ -54,7 +51,7 @@ const Dot = classed( yesulin: 'bg-chip-green', jungang: 'bg-chip-purple', orange: 'bg-chip-orange', - inactive: 'bg-zinc-200 dark:bg-slate-500', + inactive: 'bg-[var(--color-route-inactive)] dark:bg-slate-500', }, 'data-state': { hidden: 'opacity-0', @@ -85,7 +82,7 @@ const SpecialStopsText = classed( cycle: 'text-chip-red', yesulin: 'text-chip-green', jungang: 'text-chip-purple', - inactive: 'text-zinc-200 dark:text-slate-500', + inactive: 'text-[var(--color-route-inactive-text)] dark:text-slate-500', }, }, }, @@ -183,7 +180,7 @@ const RouteLines = (props: { const RouteElement = (props: { routeStatus: RouteStatus tab: string - animationFlagTable: { [key: string]: Array } + animationFlagTable: RouteAnimationFlag item: number }) => { const { t, i18n } = useTranslation() @@ -228,7 +225,7 @@ const RouteElement = (props: { const RouteElementGroup = (props: { routeStatus: RouteStatus tab: string - animationFlagTable: { [key: string]: Array } + animationFlagTable: RouteAnimationFlag }) => { return ( <> @@ -277,16 +274,15 @@ const RouteElementGroup = (props: { const RouteVisual = (props: { routeStatus: keyof RouteAnimationFlag tab: string + animationFlagTable: RouteAnimationFlag }) => { - const animationFlagTable = useDotAnimation(props.tab) - return ( ) } -export default RouteVisual +export default React.memo(RouteVisual) diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx new file mode 100644 index 00000000..89ec1494 --- /dev/null +++ b/src/app/components/seasonal/SeasonalEffect.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import { Snowfall } from 'react-snowfall' + +import { THEME, useDarkmodeContext } from '@/context/ThemeContext' + +const effectStyle: React.CSSProperties = { + zIndex: 2, + position: 'fixed', + width: '100vw', + height: '100vh', + pointerEvents: 'none', +} + +const SummerLensFlareEffect = React.lazy( + () => import('./SummerLensFlareEffect'), +) + +const getEffectStyle = (visible: boolean): React.CSSProperties => ({ + ...effectStyle, + opacity: visible ? 1 : 0, +}) + +const createImage = (src: string): HTMLImageElement => { + const image = document.createElement('img') + image.src = src + + return image +} + +const getEffectTheme = ( + theme: THEME, + manualSeasonalTheme: THEME | null, + automaticSeasonTheme: THEME, +): THEME => { + if (theme === THEME.LIGHT || theme === THEME.DARK) { + return manualSeasonalTheme ?? automaticSeasonTheme + } + + return theme +} + +const SeasonalEffect = () => { + const { + theme, + automaticSeasonTheme, + seasonalThemeEnabled, + manualSeasonalTheme, + seasonalThemePreview, + } = useDarkmodeContext() + const effectTheme = getEffectTheme( + theme, + manualSeasonalTheme, + automaticSeasonTheme, + ) + const effectVisible = seasonalThemeEnabled || seasonalThemePreview + const springImages = React.useMemo( + () => [ + createImage('/image/flower_pink.png'), + createImage('/image/flower_bpink.png'), + ], + [], + ) + const autumnImages = React.useMemo( + () => [ + createImage('/image/autumn_orange.svg'), + createImage('/image/autumn_brown.svg'), + ], + [], + ) + + const style = getEffectStyle(effectVisible) + + if (effectTheme === THEME.SPRING) { + return ( + + ) + } + + if (effectTheme === THEME.SUMMER) { + if (!effectVisible) return null + + return ( + + + + ) + } + + if (effectTheme === THEME.AUTUMN) { + return ( + + ) + } + + if (effectTheme === THEME.WINTER || effectTheme === THEME.CHRISTMAS) { + return ( + + ) + } + + return null +} + +export default SeasonalEffect diff --git a/src/app/components/seasonal/SummerLensFlareEffect.tsx b/src/app/components/seasonal/SummerLensFlareEffect.tsx new file mode 100644 index 00000000..a3fa8c14 --- /dev/null +++ b/src/app/components/seasonal/SummerLensFlareEffect.tsx @@ -0,0 +1,79 @@ +import React from 'react' + +import { THEME, useDarkmodeContext } from '@/context/ThemeContext' + +import SummerGodlightRays from './godlights/SummerGodlightRays' +import OpticalLensFlare, { + type OpticalLensFlareProps, +} from './lens-flare/OpticalLensFlare' + +const overlayStyle: React.CSSProperties = { + position: 'fixed', + inset: 0, + zIndex: 2, + width: '100vw', + height: '100dvh', + overflow: 'hidden', + pointerEvents: 'none', +} + +const lensStyle: React.CSSProperties = { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + mixBlendMode: 'normal', + opacity: 0.9, + pointerEvents: 'none', +} + +const lensFlareOptions = { + ghosts: { + apertureSides: 8, + breathe: 0, + chroma: 0.026, + colorA: '#fff2cf', + colorB: '#7be5ff', + count: 18, + drift: 1.72, + driftSpeed: 1 / 96, + edgeSoftness: 0.042, + fadeVariation: 0.58, + intensity: 4.4, + ringIntensity: 1.2, + scale: 1, + scatter: 0.018, + spread: 0.74, + }, + intensity: 1, + maxDpr: 1.25, + motion: { mode: 'static', persistAcrossMounts: true }, + opticalCenter: { x: 0.5, y: 0.5 }, + rays: { intensity: 0 }, + resolutionScale: 0.64, + source: { x: 0, y: 0 }, + sourceStyle: { coreIntensity: 0, haloIntensity: 0 }, + streak: { intensity: 0 }, + style: lensStyle, +} satisfies OpticalLensFlareProps + +const SummerLensFlareEffect = () => { + const { theme } = useDarkmodeContext() + + if (theme === THEME.DARK) { + return null + } + + return ( + + ) +} + +export default SummerLensFlareEffect diff --git a/src/app/components/seasonal/godlights/StaticGodLights.tsx b/src/app/components/seasonal/godlights/StaticGodLights.tsx new file mode 100644 index 00000000..6a03ae69 --- /dev/null +++ b/src/app/components/seasonal/godlights/StaticGodLights.tsx @@ -0,0 +1,97 @@ +import { GodLights, type SceneConfig } from 'godlights' +import React from 'react' + +import { + renderStaticGodLights, + supportsStaticGodLightsWorker, +} from './staticGodLights.client' + +type StaticGodLightsProps = Readonly<{ + scene: SceneConfig + className?: string + style?: React.CSSProperties +}> + +const canvasStyle: React.CSSProperties = { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + width: '100%', + height: '100%', + display: 'block', +} + +const StaticGodLights = ({ scene, className, style }: StaticGodLightsProps) => { + const canvasRef = React.useRef(null) + const [useMainThreadFallback, setUseMainThreadFallback] = React.useState( + () => !supportsStaticGodLightsWorker(), + ) + + React.useEffect(() => { + if (useMainThreadFallback) return + + const canvas = canvasRef.current + const render = renderStaticGodLights(scene) + if (!canvas || !render) { + setUseMainThreadFallback(true) + return + } + + canvas.dataset.godlightsRenderStatus = 'pending' + let active = true + + void render.bitmap.then( + (bitmap) => { + if (!active) { + bitmap.close() + return + } + + try { + const context = canvas.getContext('2d') + if (!context) throw new Error('Canvas 2D context is unavailable') + + context.clearRect(0, 0, scene.width, scene.height) + context.drawImage(bitmap, 0, 0) + canvas.dataset.godlightsPresenter = '2d-copy' + canvas.dataset.godlightsRenderStatus = 'ready' + } catch { + if (active) setUseMainThreadFallback(true) + } finally { + bitmap.close() + } + }, + () => { + if (active) setUseMainThreadFallback(true) + }, + ) + + return () => { + active = false + render.cancel() + } + }, [scene, useMainThreadFallback]) + + if (useMainThreadFallback) { + return + } + + return ( +
+ +
+ ) +} + +export default StaticGodLights diff --git a/src/app/components/seasonal/godlights/SummerGodlightRays.tsx b/src/app/components/seasonal/godlights/SummerGodlightRays.tsx new file mode 100644 index 00000000..50347da1 --- /dev/null +++ b/src/app/components/seasonal/godlights/SummerGodlightRays.tsx @@ -0,0 +1,443 @@ +import { type SceneConfig } from 'godlights' +import { motion, useReducedMotion } from 'motion/react' +import React from 'react' + +import StaticGodLights from './StaticGodLights' + +type ViewportSize = Readonly<{ + width: number + height: number +}> + +type RenderMetrics = ViewportSize & + Readonly<{ + scale: number + }> + +type RaySpec = Readonly<{ + id: string + direction: number + rayWidth: number + divergence: number + fadeEndY: number + blur: number + baseOpacity: number + peakOpacity: number + duration: number + phase: number + reducedOpacity: number +}> + +const TIMELINE_STARTED_AT = + typeof performance === 'undefined' ? 0 : performance.now() + +const REFERENCE_WIDTH = 1920 +const REFERENCE_HEIGHT = 1080 +const MINIMUM_RAY_SCALE = 0.55 +const MAXIMUM_RAY_SCALE = 1.25 +const MAXIMUM_RENDER_SCALE = 0.82 +const MAXIMUM_RENDER_PIXELS_PER_LAYER = 600_000 +const EDGE_ALPHA_TARGET = 0.05 +const ORIGIN_X = 0 +const ORIGIN_Y = -1.5 +const SOURCE_HALO_ORIGIN_Y = 0 +const SOURCE_LAYER_OPACITY = 0.9 +const SOURCE_HALO_INTENSITY = 0.9 +const SOURCE_HALO_SIZE = 0.07 +const SOURCE_CORE_HALO_INTENSITY = 1 +const SOURCE_CORE_HALO_SIZE = 0.03 +const BREATHING_TIMES = [0, 0.22, 0.34, 0.4, 0.54, 1] + +const toGodlightsDirection = (screenAngle: number): number => screenAngle + 90 + +const raySpecs: readonly RaySpec[] = [ + { + id: 'far-right-thread', + direction: toGodlightsDirection(25), + rayWidth: 7, + divergence: 4.2, + fadeEndY: 0.6, + blur: 6, + baseOpacity: 0.12, + peakOpacity: 0.33, + duration: 67, + phase: 0.08, + reducedOpacity: 0.12, + }, + { + id: 'right-whisper-thread', + direction: toGodlightsDirection(35), + rayWidth: 10, + divergence: 3.8, + fadeEndY: 0.63, + blur: 7, + baseOpacity: 0.022, + peakOpacity: 0.27, + duration: 73, + phase: 0.46, + reducedOpacity: 0.022, + }, + { + id: 'inner-right-thread', + direction: toGodlightsDirection(45), + rayWidth: 6.5, + divergence: 4.8, + fadeEndY: 0.52, + blur: 4.5, + baseOpacity: 0.025, + peakOpacity: 0.3, + duration: 59, + phase: 0.81, + reducedOpacity: 0.025, + }, + { + id: 'center-anchor-thread', + direction: toGodlightsDirection(55), + rayWidth: 9, + divergence: 4.1, + fadeEndY: 0.53, + blur: 6, + baseOpacity: 0.13, + peakOpacity: 0.36, + duration: 79, + phase: 0.24, + reducedOpacity: 0.13, + }, + { + id: 'reference-anchor-thread', + direction: toGodlightsDirection(75), + rayWidth: 8, + divergence: 4.4, + fadeEndY: 0.67, + blur: 6, + baseOpacity: 0.14, + peakOpacity: 0.38, + duration: 83, + phase: 0.61, + reducedOpacity: 0.14, + }, + { + id: 'inner-left-thread', + direction: toGodlightsDirection(65), + rayWidth: 6.5, + divergence: 5, + fadeEndY: 0.6, + blur: 4.5, + baseOpacity: 0.025, + peakOpacity: 0.29, + duration: 61, + phase: 0.92, + reducedOpacity: 0.025, + }, + { + id: 'left-whisper-thread', + direction: toGodlightsDirection(82), + rayWidth: 9, + divergence: 4, + fadeEndY: 0.46, + blur: 6, + baseOpacity: 0.012, + peakOpacity: 0.16, + duration: 71, + phase: 0.34, + reducedOpacity: 0.012, + }, + { + id: 'outer-right-whisper-thread', + direction: toGodlightsDirection(18), + rayWidth: 7, + divergence: 4.6, + fadeEndY: 0.48, + blur: 5, + baseOpacity: 0.012, + peakOpacity: 0.16, + duration: 89, + phase: 0.74, + reducedOpacity: 0.012, + }, +] + +const layerStyle: React.CSSProperties = { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + pointerEvents: 'none', +} + +const getInitialViewportSize = (): ViewportSize => { + if (typeof window === 'undefined') return { width: 1280, height: 720 } + + return { + width: Math.max(1, Math.round(window.innerWidth)), + height: Math.max(1, Math.round(window.innerHeight)), + } +} + +const getRayScale = ({ width, height }: ViewportSize): number => + Math.max( + MINIMUM_RAY_SCALE, + Math.min( + MAXIMUM_RAY_SCALE, + Math.min(width / REFERENCE_WIDTH, height / REFERENCE_HEIGHT), + ), + ) + +const getRenderMetrics = ({ width, height }: ViewportSize): RenderMetrics => { + const viewportPixels = Math.max(1, width * height) + const scale = Math.min( + MAXIMUM_RENDER_SCALE, + Math.sqrt(MAXIMUM_RENDER_PIXELS_PER_LAYER / viewportPixels), + ) + + return { + width: Math.max(1, Math.floor(width * scale)), + height: Math.max(1, Math.floor(height * scale)), + scale, + } +} + +const getRayLength = ( + { width, height }: ViewportSize, + spec: RaySpec, +): number => { + const angle = ((spec.direction - 90) * Math.PI) / 180 + const directionX = Math.cos(angle) + const directionY = Math.sin(angle) + const originX = (ORIGIN_X / 100) * width + const originY = (ORIGIN_Y / 100) * height + const exitDistances = [ + directionX > 0 ? (width - originX) / directionX : -originX / directionX, + directionY > 0 ? (height - originY) / directionY : -originY / directionY, + ].filter((distance) => Number.isFinite(distance) && distance > 0) + const exitDistance = Math.min(...exitDistances) + const targetY = spec.fadeEndY * height + const targetDistance = (targetY - originY) / directionY + const edgeFadeDistance = exitDistance / (1 - EDGE_ALPHA_TARGET) + const rayDistance = Math.min(targetDistance, edgeFadeDistance) + + return rayDistance / Math.hypot(width, height) +} + +const createSourceScene = (renderMetrics: RenderMetrics): SceneConfig => ({ + width: renderMetrics.width, + height: renderMetrics.height, + noise: 0, + grainSize: 1, + layers: [ + { + type: 'background', + bgType: 'solid', + bgColor: 'rgba(0, 0, 0, 0)', + bgColor2: 'rgba(0, 0, 0, 0)', + bgGradientAngle: 0, + }, + { + type: 'halo', + originX: ORIGIN_X, + originY: SOURCE_HALO_ORIGIN_Y, + intensity: SOURCE_HALO_INTENSITY, + size: SOURCE_HALO_SIZE, + color: '#f7fcff', + blendMode: 'source-over', + }, + { + type: 'halo', + originX: ORIGIN_X, + originY: SOURCE_HALO_ORIGIN_Y, + intensity: SOURCE_CORE_HALO_INTENSITY, + size: SOURCE_CORE_HALO_SIZE, + color: '#ffffff', + blendMode: 'source-over', + }, + ], +}) + +const createScene = ( + viewportSize: ViewportSize, + renderMetrics: RenderMetrics, + spec: RaySpec, + index: number, +): SceneConfig => { + const rayScale = getRayScale(viewportSize) * renderMetrics.scale + + return { + width: renderMetrics.width, + height: renderMetrics.height, + noise: 0, + grainSize: 1, + layers: [ + { + type: 'background', + bgType: 'solid', + bgColor: 'rgba(0, 0, 0, 0)', + bgColor2: 'rgba(0, 0, 0, 0)', + bgGradientAngle: 0, + }, + { + type: 'rays', + direction: spec.direction, + spread: 1, + originX: ORIGIN_X, + originY: ORIGIN_Y, + rayCount: 1, + rayWidth: spec.rayWidth * rayScale, + divergence: spec.divergence, + rayLength: getRayLength(renderMetrics, spec), + opacity: 1, + blendMode: 'source-over', + colorStart: '#f6fdff', + colorEnd: '#f6fdff', + fadeToTransparent: true, + blur: spec.blur * rayScale, + randomnessWidth: 0, + randomnessLength: 0, + randomnessAngle: 0, + seed: 2207 + index * 977, + }, + ], + } +} + +const getTimelineDelay = (spec: RaySpec): number => { + if (typeof performance === 'undefined') return -(spec.phase * spec.duration) + + const elapsed = (performance.now() - TIMELINE_STARTED_AT) / 1000 + const timelinePosition = + (elapsed + spec.phase * spec.duration) % spec.duration + + return -timelinePosition +} + +const SummerGodlightRays = () => { + const reduceMotion = useReducedMotion() === true + const containerRef = React.useRef(null) + const [viewportSize, setViewportSize] = React.useState( + getInitialViewportSize, + ) + + React.useEffect(() => { + const container = containerRef.current + if (!container) return + + let resizeFrame = 0 + const updateSize = (width: number, height: number) => { + window.cancelAnimationFrame(resizeFrame) + resizeFrame = window.requestAnimationFrame(() => { + const nextSize = { + width: Math.max(1, Math.round(width)), + height: Math.max(1, Math.round(height)), + } + + setViewportSize((currentSize) => { + if ( + currentSize.width === nextSize.width && + currentSize.height === nextSize.height + ) { + return currentSize + } + + return nextSize + }) + }) + } + + const observer = new ResizeObserver(([entry]) => { + if (!entry) return + updateSize(entry.contentRect.width, entry.contentRect.height) + }) + const initialBounds = container.getBoundingClientRect() + + updateSize(initialBounds.width, initialBounds.height) + observer.observe(container) + + return () => { + window.cancelAnimationFrame(resizeFrame) + observer.disconnect() + } + }, []) + + const renderMetrics = React.useMemo( + () => getRenderMetrics(viewportSize), + [viewportSize], + ) + + const scenes = React.useMemo( + () => + raySpecs.map((spec, index) => + createScene(viewportSize, renderMetrics, spec, index), + ), + [renderMetrics, viewportSize], + ) + const sourceScene = React.useMemo( + () => createSourceScene(renderMetrics), + [renderMetrics], + ) + + return ( +
+
+ +
+ {raySpecs.map((spec, index) => ( + + + + ))} +
+ ) +} + +export default SummerGodlightRays diff --git a/src/app/components/seasonal/godlights/staticGodLights.client.ts b/src/app/components/seasonal/godlights/staticGodLights.client.ts new file mode 100644 index 00000000..2b091d7a --- /dev/null +++ b/src/app/components/seasonal/godlights/staticGodLights.client.ts @@ -0,0 +1,150 @@ +import type { SceneConfig } from 'godlights' + +type RenderRequestMessage = Readonly<{ + type: 'render' + id: number + scene: SceneConfig +}> + +type RenderSuccessMessage = Readonly<{ + type: 'rendered' + id: number + bitmap: ImageBitmap +}> + +type RenderErrorMessage = Readonly<{ + type: 'error' + id: number + message: string +}> + +type RenderResponseMessage = RenderSuccessMessage | RenderErrorMessage + +type PendingRender = Readonly<{ + resolve: (bitmap: ImageBitmap) => void + reject: (error: Error) => void +}> + +export type StaticGodLightsRender = Readonly<{ + bitmap: Promise + cancel: () => void +}> + +let nextRequestId = 1 +let renderWorker: Worker | null = null +let workerUnavailable = false +const pendingRenders = new Map() + +const toError = (error: unknown): Error => + error instanceof Error ? error : new Error(String(error)) + +const releaseIdleWorker = () => { + if (pendingRenders.size > 0 || !renderWorker) return + + renderWorker.terminate() + renderWorker = null +} + +const disableWorker = (error: Error) => { + workerUnavailable = true + renderWorker?.terminate() + renderWorker = null + + pendingRenders.forEach(({ reject }) => reject(error)) + pendingRenders.clear() +} + +const handleWorkerMessage = (event: MessageEvent) => { + const response = event.data + const pendingRender = pendingRenders.get(response.id) + + if (!pendingRender) { + if (response.type === 'rendered') response.bitmap.close() + return + } + + pendingRenders.delete(response.id) + + if (response.type === 'rendered') { + pendingRender.resolve(response.bitmap) + releaseIdleWorker() + return + } + + const error = new Error(response.message) + pendingRender.reject(error) + disableWorker(error) +} + +const createRenderWorker = (): Worker | null => { + if (workerUnavailable) return null + + try { + const worker = new Worker( + new URL('./staticGodLights.worker.ts', import.meta.url), + { type: 'module' }, + ) + + worker.addEventListener('message', handleWorkerMessage) + worker.addEventListener('error', (event) => { + disableWorker(new Error(event.message || 'GodLights worker failed')) + }) + worker.addEventListener('messageerror', () => { + disableWorker(new Error('GodLights worker returned an invalid message')) + }) + + return worker + } catch (error) { + disableWorker(toError(error)) + return null + } +} + +export const supportsStaticGodLightsWorker = (): boolean => + !workerUnavailable && + typeof window !== 'undefined' && + typeof Worker !== 'undefined' && + typeof OffscreenCanvas !== 'undefined' && + typeof ImageBitmap !== 'undefined' + +export const renderStaticGodLights = ( + scene: SceneConfig, +): StaticGodLightsRender | null => { + if (!supportsStaticGodLightsWorker()) return null + + const worker = renderWorker ?? createRenderWorker() + if (!worker) return null + + renderWorker = worker + const id = nextRequestId++ + let rejectRender: (error: Error) => void = () => undefined + const bitmap = new Promise((resolve, reject) => { + rejectRender = reject + pendingRenders.set(id, { resolve, reject }) + }) + + try { + worker.postMessage({ + type: 'render', + id, + scene, + } satisfies RenderRequestMessage) + } catch (error) { + const renderError = toError(error) + pendingRenders.delete(id) + rejectRender(renderError) + disableWorker(renderError) + } + + return { + bitmap, + cancel: () => { + const pendingRender = pendingRenders.get(id) + if (!pendingRender) return + + pendingRenders.delete(id) + pendingRender.reject(new Error('GodLights worker render cancelled')) + releaseIdleWorker() + }, + } +} diff --git a/src/app/components/seasonal/godlights/staticGodLights.worker.ts b/src/app/components/seasonal/godlights/staticGodLights.worker.ts new file mode 100644 index 00000000..e8cfac93 --- /dev/null +++ b/src/app/components/seasonal/godlights/staticGodLights.worker.ts @@ -0,0 +1,88 @@ +import { drawScene, type SceneConfig } from 'godlights' + +type RenderRequestMessage = Readonly<{ + type: 'render' + id: number + scene: SceneConfig +}> + +type RenderSuccessMessage = Readonly<{ + type: 'rendered' + id: number + bitmap: ImageBitmap +}> + +type RenderErrorMessage = Readonly<{ + type: 'error' + id: number + message: string +}> + +type WorkerScope = Readonly<{ + addEventListener: ( + type: 'message', + listener: (event: MessageEvent) => void, + ) => void + postMessage: ( + message: RenderSuccessMessage | RenderErrorMessage, + transfer?: Transferable[], + ) => void +}> + +const workerScope = self as unknown as WorkerScope + +const supportsRequiredCanvasFeatures = (): boolean => { + if (typeof OffscreenCanvas === 'undefined') return false + + try { + const probeCanvas = new OffscreenCanvas(2, 2) + const context = probeCanvas.getContext('2d') + if (!context || !('filter' in context)) return false + + context.filter = 'blur(1px)' + + return ( + context.filter === 'blur(1px)' && + typeof probeCanvas.transferToImageBitmap === 'function' + ) + } catch { + return false + } +} + +const canvasFeaturesSupported = supportsRequiredCanvasFeatures() + +workerScope.addEventListener('message', (event) => { + const request = event.data + if (request.type !== 'render') return + + let bitmap: ImageBitmap | null = null + + try { + if (!canvasFeaturesSupported) { + throw new Error('Required OffscreenCanvas features are unavailable') + } + + const canvas = new OffscreenCanvas( + request.scene.width, + request.scene.height, + ) + + // godlights types the public draw target as HTMLCanvasElement, but its + // implementation only uses the Canvas 2D surface shared by OffscreenCanvas. + drawScene(canvas as unknown as HTMLCanvasElement, request.scene) + bitmap = canvas.transferToImageBitmap() + + workerScope.postMessage({ type: 'rendered', id: request.id, bitmap }, [ + bitmap, + ]) + bitmap = null + } catch (error) { + bitmap?.close() + workerScope.postMessage({ + type: 'error', + id: request.id, + message: error instanceof Error ? error.message : String(error), + }) + } +}) diff --git a/src/app/components/seasonal/lens-flare/OpticalLensFlare.tsx b/src/app/components/seasonal/lens-flare/OpticalLensFlare.tsx new file mode 100644 index 00000000..1e0f3ce5 --- /dev/null +++ b/src/app/components/seasonal/lens-flare/OpticalLensFlare.tsx @@ -0,0 +1,685 @@ +import React from 'react' + +import { + LensFlareUnsupportedError, + OpticalLensFlareRenderer, + toAbsoluteLensFlareTime, +} from './opticalLensFlareRenderer' +import type { + LensFlareFrameSnapshot, + LensFlarePoint, + LensFlareRenderSize, + LensFlareStatus, + LensFlareTimeline, + LensFlareWorkerRequest, + LensFlareWorkerResponse, + OpticalLensFlareProps, + ResolvedLensFlareOptions, + RgbColor, + SerializedLensFlareError, +} from './opticalLensFlareTypes' + +export type { + LensFlareGhostStyle, + LensFlareMotion, + LensFlareMotionMode, + LensFlareMotionPreference, + LensFlarePoint, + LensFlareRayStyle, + LensFlareSourceStyle, + LensFlareStatus, + LensFlareStreakStyle, + OpticalLensFlareProps, +} from './opticalLensFlareTypes' + +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' +const MAX_GHOSTS = 18 +const PERSISTENT_MOTION_STARTED_AT = performance.now() +const PERSISTENT_MOTION_STARTED_AT_ABSOLUTE = toAbsoluteLensFlareTime( + PERSISTENT_MOTION_STARTED_AT, +) + +type LensFlareBackend = Readonly<{ + destroy: () => void + requestRender: () => void + resize: (size: LensFlareRenderSize) => void + setVisibility: (hidden: boolean) => void + updateOptions: ( + options: ResolvedLensFlareOptions, + reducedMotion: boolean, + ) => void + updatePointer: (point: LensFlarePoint) => void +}> + +type LensFlareBackendCallbacks = Readonly<{ + onError: (error: unknown) => void + onFrame: (snapshot: LensFlareFrameSnapshot) => void + onStatus: (status: LensFlareStatus) => void +}> + +const clamp = (value: number, minimum: number, maximum: number): number => + Math.min(Math.max(value, minimum), maximum) + +const parseHexColor = (value: string, fallback: RgbColor): RgbColor => { + const match = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value.trim()) + + if (!match) return fallback + + const raw = match[1] + const expanded = + raw.length === 3 + ? raw + .split('') + .map((character) => `${character}${character}`) + .join('') + : raw + const integer = Number.parseInt(expanded, 16) + + return [ + ((integer >> 16) & 255) / 255, + ((integer >> 8) & 255) / 255, + (integer & 255) / 255, + ] +} + +const resolveOptions = ({ + source, + opticalCenter, + motion, + sourceStyle, + rays, + streak, + ghosts, + intensity, + maxDpr, + resolutionScale, + motionPreference, +}: OpticalLensFlareProps): ResolvedLensFlareOptions => ({ + source: { + x: source?.x ?? 0.2, + y: source?.y ?? 0.035, + }, + opticalCenter: { + x: opticalCenter?.x ?? 0.52, + y: opticalCenter?.y ?? 0.61, + }, + motion: { + mode: motion?.mode ?? 'horizontal', + amplitude: { + x: Math.max(motion?.amplitude?.x ?? 0.18, 0), + y: Math.max(motion?.amplitude?.y ?? 0, 0), + }, + speed: Math.max(motion?.speed ?? 0.035, 0), + phase: motion?.phase ?? 0, + settlingTime: Math.max(motion?.settlingTime ?? 0.38, 0.001), + persistAcrossMounts: motion?.persistAcrossMounts ?? false, + }, + sourceStyle: { + coreColor: parseHexColor( + sourceStyle?.coreColor ?? '#fff9e8', + [1, 0.97, 0.88], + ), + haloColor: parseHexColor( + sourceStyle?.haloColor ?? '#ffdcb3', + [1, 0.82, 0.62], + ), + coreRadius: Math.max(sourceStyle?.coreRadius ?? 0.032, 0.001), + coreIntensity: Math.max(sourceStyle?.coreIntensity ?? 1.12, 0), + haloRadius: Math.max(sourceStyle?.haloRadius ?? 0.18, 0.002), + haloIntensity: Math.max(sourceStyle?.haloIntensity ?? 0.24, 0), + }, + rays: { + color: parseHexColor(rays?.color ?? '#c8edff', [0.78, 0.92, 1]), + length: Math.max(rays?.length ?? 0.82, 0.001), + intensity: Math.max(rays?.intensity ?? 0.095, 0), + count: clamp(Math.round(rays?.count ?? 6), 2, 12), + softness: clamp(rays?.softness ?? 0.78, 0, 1), + angle: rays?.angle ?? -0.1, + }, + streak: { + color: parseHexColor(streak?.color ?? '#b9e8ff', [0.73, 0.91, 1]), + length: Math.max(streak?.length ?? 1.05, 0.001), + width: Math.max(streak?.width ?? 0.005, 0.0005), + intensity: Math.max(streak?.intensity ?? 0.13, 0), + angle: streak?.angle ?? -0.018, + }, + ghosts: { + colorA: parseHexColor(ghosts?.colorA ?? '#fff1d8', [1, 0.91, 0.77]), + colorB: parseHexColor(ghosts?.colorB ?? '#79e9ff', [0.48, 0.91, 1]), + count: clamp(Math.round(ghosts?.count ?? 9), 0, MAX_GHOSTS), + spread: Math.max(ghosts?.spread ?? 1, 0), + scale: Math.max(ghosts?.scale ?? 1.08, 0), + intensity: Math.max(ghosts?.intensity ?? 0.54, 0), + apertureSides: clamp(Math.round(ghosts?.apertureSides ?? 6), 3, 12), + chroma: clamp(ghosts?.chroma ?? 0.12, 0, 0.5), + edgeSoftness: clamp(ghosts?.edgeSoftness ?? 0.045, 0.005, 0.25), + ringIntensity: clamp(ghosts?.ringIntensity ?? 0.6, 0, 1.5), + breathe: clamp(ghosts?.breathe ?? 0.025, 0, 0.25), + drift: clamp(ghosts?.drift ?? 0, 0, 2.5), + driftSpeed: clamp(ghosts?.driftSpeed ?? 0, 0, 0.2), + fadeVariation: clamp(ghosts?.fadeVariation ?? 0, 0, 0.98), + scatter: clamp(ghosts?.scatter ?? 0, 0, 0.08), + }, + intensity: Math.max(intensity ?? 0.82, 0), + maxDpr: clamp(maxDpr ?? 1.5, 0.5, 2), + resolutionScale: clamp(resolutionScale ?? 0.9, 0.5, 1), + motionPreference: motionPreference ?? 'system', +}) + +const shouldReduceMotion = ( + options: ResolvedLensFlareOptions, + systemPreference: boolean, +): boolean => + options.motionPreference === 'reduce' || + (options.motionPreference === 'system' && systemPreference) + +const getRenderSize = ( + canvas: HTMLCanvasElement, + options: ResolvedLensFlareOptions, +): LensFlareRenderSize => { + const bounds = canvas.getBoundingClientRect() + const dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr) + const scale = dpr * options.resolutionScale + + return { + height: Math.max(Math.round(bounds.height * scale), 1), + width: Math.max(Math.round(bounds.width * scale), 1), + } +} + +const updateCanvasDiagnostics = ( + canvas: HTMLCanvasElement, + snapshot: LensFlareFrameSnapshot, +): void => { + canvas.dataset.lensFlareSourceX = snapshot.source.x.toFixed(4) + canvas.dataset.lensFlareSourceY = snapshot.source.y.toFixed(4) + canvas.dataset.lensFlareGhostCount = String(snapshot.ghostCount) + canvas.dataset.lensFlareGhostDrift = snapshot.ghostDrift.toFixed(4) + canvas.dataset.lensFlareMotionElapsed = snapshot.elapsed.toFixed(3) + canvas.dataset.lensFlareMotionTimeline = snapshot.timeline +} + +const deserializeError = (error: SerializedLensFlareError): Error => { + const normalizedError = new Error(error.message) + normalizedError.name = error.name + if (error.stack) normalizedError.stack = error.stack + + return normalizedError +} + +const createMainThreadBackend = ( + canvas: HTMLCanvasElement, + options: ResolvedLensFlareOptions, + timeline: LensFlareTimeline, + initialReducedMotion: boolean, + initialHidden: boolean, + initialPointer: LensFlarePoint, + initialSize: LensFlareRenderSize, + callbacks: LensFlareBackendCallbacks, +): LensFlareBackend => { + const renderer = new OpticalLensFlareRenderer(canvas, options, timeline) + let currentOptions = options + let reducedMotion = initialReducedMotion + let hidden = initialHidden + let contextLost = false + let frameId: number | null = null + let destroyed = false + + renderer.updatePointer(initialPointer) + renderer.resize(initialSize) + + const cancelFrame = (): void => { + if (frameId === null) return + + window.cancelAnimationFrame(frameId) + frameId = null + } + + const requestFrame = (): void => { + if (destroyed || hidden || contextLost || frameId !== null) return + + frameId = window.requestAnimationFrame(draw) + } + + const draw = (timestamp: number): void => { + frameId = null + if (destroyed || hidden || contextLost) return + + try { + const snapshot = renderer.draw( + toAbsoluteLensFlareTime(timestamp), + reducedMotion, + ) + + if (snapshot) callbacks.onFrame(snapshot) + if (renderer.isAnimated(reducedMotion)) requestFrame() + } catch (error) { + callbacks.onError(error) + } + } + + const handleContextLost = (event: Event): void => { + event.preventDefault() + contextLost = true + cancelFrame() + callbacks.onStatus('lost') + } + + const handleContextRestored = (): void => { + callbacks.onStatus('restored') + + try { + renderer.restore() + renderer.resetLastFrameTime(toAbsoluteLensFlareTime(performance.now())) + contextLost = false + callbacks.onStatus('destroyed') + callbacks.onStatus('ready') + requestFrame() + } catch (error) { + callbacks.onError(error) + } + } + + canvas.addEventListener('webglcontextlost', handleContextLost) + canvas.addEventListener('webglcontextrestored', handleContextRestored) + callbacks.onStatus('ready') + requestFrame() + + return { + destroy: () => { + if (destroyed) return + + destroyed = true + cancelFrame() + canvas.removeEventListener('webglcontextlost', handleContextLost) + canvas.removeEventListener('webglcontextrestored', handleContextRestored) + renderer.destroy() + }, + requestRender: requestFrame, + resize: (size) => { + renderer.resize(size) + requestFrame() + }, + setVisibility: (nextHidden) => { + hidden = nextHidden + + if (hidden) { + cancelFrame() + } else { + renderer.resetLastFrameTime(toAbsoluteLensFlareTime(performance.now())) + requestFrame() + } + }, + updateOptions: (nextOptions, nextReducedMotion) => { + currentOptions = nextOptions + reducedMotion = nextReducedMotion + renderer.updateOptions(nextOptions) + requestFrame() + }, + updatePointer: (point) => { + renderer.updatePointer(point) + if (currentOptions.motion.mode === 'pointer') requestFrame() + }, + } +} + +const createWorkerBackend = ( + worker: Worker, + initialOptions: ResolvedLensFlareOptions, + initialPointer: LensFlarePoint, +): LensFlareBackend => { + let currentOptions = initialOptions + let pointer = initialPointer + const postMessage = (message: LensFlareWorkerRequest): void => { + worker.postMessage(message) + } + + return { + destroy: () => postMessage({ type: 'destroy' }), + requestRender: () => postMessage({ type: 'render' }), + resize: (size) => postMessage({ type: 'resize', size }), + setVisibility: (hidden) => postMessage({ type: 'visibility', hidden }), + updateOptions: (options, reducedMotion) => { + currentOptions = options + if (options.motion.mode === 'pointer') { + postMessage({ type: 'pointer', point: pointer }) + } + postMessage({ type: 'options', options, reducedMotion }) + }, + updatePointer: (point) => { + pointer = point + if (currentOptions.motion.mode === 'pointer') { + postMessage({ type: 'pointer', point }) + } + }, + } +} + +const OpticalLensFlare = ({ + source, + opticalCenter, + motion, + sourceStyle, + rays, + streak, + ghosts, + intensity, + maxDpr, + resolutionScale, + motionPreference, + className, + style, + onError, + onStatusChange, +}: OpticalLensFlareProps) => { + const resolvedOptions = React.useMemo( + () => + resolveOptions({ + source, + opticalCenter, + motion, + sourceStyle, + rays, + streak, + ghosts, + intensity, + maxDpr, + resolutionScale, + motionPreference, + }), + [ + source, + opticalCenter, + motion, + sourceStyle, + rays, + streak, + ghosts, + intensity, + maxDpr, + resolutionScale, + motionPreference, + ], + ) + const canvasRef = React.useRef(null) + const optionsRef = React.useRef(resolvedOptions) + const requestRenderRef = React.useRef<(() => void) | null>(null) + const onErrorRef = React.useRef(onError) + const onStatusChangeRef = React.useRef(onStatusChange) + const forceMainThreadRef = React.useRef(false) + const [canvasVersion, setCanvasVersion] = React.useState(0) + const [status, setStatus] = React.useState('idle') + + React.useLayoutEffect(() => { + optionsRef.current = resolvedOptions + onErrorRef.current = onError + onStatusChangeRef.current = onStatusChange + requestRenderRef.current?.() + }, [onError, onStatusChange, resolvedOptions]) + + React.useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + let disposed = false + let backend: LensFlareBackend | null = null + let worker: Worker | null = null + let workerTransferred = false + let switchingToMainThread = false + let hasRenderer = false + let systemReducedMotion = window.matchMedia(REDUCED_MOTION_QUERY).matches + let pointerSource = { ...optionsRef.current.source } + const timeline: LensFlareTimeline = { + mountStartedAt: toAbsoluteLensFlareTime(performance.now()), + persistentStartedAt: PERSISTENT_MOTION_STARTED_AT_ABSOLUTE, + } + + const updateStatus = (nextStatus: LensFlareStatus): void => { + if (disposed) return + if (nextStatus === 'ready') hasRenderer = true + setStatus(nextStatus) + onStatusChangeRef.current?.(nextStatus) + } + + const reportError = (error: unknown): void => { + const normalizedError = + error instanceof Error ? error : new Error(String(error)) + updateStatus('error') + onErrorRef.current?.(normalizedError) + } + + const getReducedMotion = (): boolean => + shouldReduceMotion(optionsRef.current, systemReducedMotion) + + const callbacks: LensFlareBackendCallbacks = { + onError: reportError, + onFrame: (snapshot) => { + if (!disposed) updateCanvasDiagnostics(canvas, snapshot) + }, + onStatus: updateStatus, + } + + const startMainThreadBackend = (): void => { + if (disposed) return + + try { + canvas.dataset.lensFlareBackend = 'main-thread' + backend = createMainThreadBackend( + canvas, + optionsRef.current, + timeline, + getReducedMotion(), + document.hidden, + pointerSource, + getRenderSize(canvas, optionsRef.current), + callbacks, + ) + } catch (error) { + if (error instanceof LensFlareUnsupportedError) { + updateStatus('unsupported') + onErrorRef.current?.(error) + return + } + + reportError(error) + } + } + + const replaceCanvasWithMainThreadBackend = (): void => { + if (disposed || switchingToMainThread) return + + switchingToMainThread = true + forceMainThreadRef.current = true + backend = null + worker?.terminate() + worker = null + setCanvasVersion((version) => version + 1) + } + + const handleWorkerFailure = (): void => { + if (disposed) return + + worker?.terminate() + worker = null + + if (workerTransferred) { + replaceCanvasWithMainThreadBackend() + } else { + startMainThreadBackend() + } + } + + const startWorkerBackend = (): void => { + try { + worker = new Worker( + new URL('./opticalLensFlare.worker.ts', import.meta.url), + { + name: 'hybus-optical-lens-flare', + type: 'module', + }, + ) + } catch { + startMainThreadBackend() + return + } + + worker.addEventListener( + 'message', + (event: MessageEvent) => { + if (disposed || !worker) return + + const message = event.data + + if (message.type === 'probe-result') { + if (!message.supported) { + worker.terminate() + worker = null + startMainThreadBackend() + return + } + + try { + const offscreenCanvas = canvas.transferControlToOffscreen() + workerTransferred = true + canvas.dataset.lensFlareBackend = 'worker-offscreen' + backend = createWorkerBackend( + worker, + optionsRef.current, + pointerSource, + ) + const initMessage: Extract< + LensFlareWorkerRequest, + { type: 'init' } + > = { + type: 'init', + canvas: offscreenCanvas, + hidden: document.hidden, + options: optionsRef.current, + pointer: pointerSource, + reducedMotion: getReducedMotion(), + size: getRenderSize(canvas, optionsRef.current), + timeline, + } + worker.postMessage(initMessage, [offscreenCanvas]) + } catch { + replaceCanvasWithMainThreadBackend() + } + return + } + + if (message.type === 'frame') { + updateCanvasDiagnostics(canvas, message.snapshot) + return + } + + if (message.type === 'status') { + updateStatus(message.status) + return + } + + reportError(deserializeError(message.error)) + handleWorkerFailure() + }, + ) + worker.addEventListener('error', (event) => { + event.preventDefault() + if (workerTransferred) { + reportError( + event.error instanceof Error + ? event.error + : new Error(event.message || 'The lens flare worker failed.'), + ) + } + handleWorkerFailure() + }) + worker.addEventListener('messageerror', () => { + if (workerTransferred) { + reportError(new Error('The lens flare worker response was invalid.')) + } + handleWorkerFailure() + }) + worker.postMessage({ + type: 'probe', + options: optionsRef.current, + reducedMotion: getReducedMotion(), + timeline, + } satisfies LensFlareWorkerRequest) + } + + const handlePointerMove = (event: PointerEvent): void => { + pointerSource = { + x: event.clientX / Math.max(window.innerWidth, 1), + y: event.clientY / Math.max(window.innerHeight, 1), + } + backend?.updatePointer(pointerSource) + } + + const handleVisibilityChange = (): void => { + backend?.setVisibility(document.hidden) + } + + const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY) + const handleMotionPreference = (event: MediaQueryListEvent): void => { + systemReducedMotion = event.matches + backend?.updateOptions(optionsRef.current, getReducedMotion()) + } + + const handleResize = (): void => { + backend?.resize(getRenderSize(canvas, optionsRef.current)) + } + + const resizeObserver = new ResizeObserver(handleResize) + resizeObserver.observe(canvas) + window.addEventListener('pointermove', handlePointerMove, { passive: true }) + window.addEventListener('resize', handleResize, { passive: true }) + window.visualViewport?.addEventListener('resize', handleResize, { + passive: true, + }) + document.addEventListener('visibilitychange', handleVisibilityChange) + mediaQuery.addEventListener('change', handleMotionPreference) + requestRenderRef.current = () => { + backend?.updateOptions(optionsRef.current, getReducedMotion()) + backend?.resize(getRenderSize(canvas, optionsRef.current)) + backend?.requestRender() + } + + if ( + forceMainThreadRef.current || + typeof Worker !== 'function' || + typeof canvas.transferControlToOffscreen !== 'function' + ) { + startMainThreadBackend() + } else { + startWorkerBackend() + } + + return () => { + disposed = true + requestRenderRef.current = null + resizeObserver.disconnect() + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('resize', handleResize) + window.visualViewport?.removeEventListener('resize', handleResize) + document.removeEventListener('visibilitychange', handleVisibilityChange) + mediaQuery.removeEventListener('change', handleMotionPreference) + backend?.destroy() + worker?.terminate() + if (hasRenderer) onStatusChangeRef.current?.('destroyed') + } + }, [canvasVersion]) + + return ( +