diff --git a/package.json b/package.json index 5149d76..ef1deca 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,11 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "lucide-react": "^0.545.0", + "mammoth": "^1.11.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4" + "react-router-dom": "^7.9.4", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0109e8b..fadf8be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: lucide-react: specifier: ^0.545.0 version: 0.545.0(react@19.2.0) + mammoth: + specifier: ^1.11.0 + version: 1.11.0 react: specifier: ^19.1.1 version: 19.2.0 @@ -26,6 +29,9 @@ importers: react-router-dom: specifier: ^7.9.4 version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + zustand: + specifier: ^5.0.8 + version: 5.0.8(@types/react@19.2.2)(react@19.2.0) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -723,6 +729,10 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -740,6 +750,9 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -759,10 +772,16 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.8.16: resolution: {integrity: sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==} hasBin: true + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -822,6 +841,9 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -849,6 +871,12 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1072,6 +1100,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1080,6 +1111,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1092,6 +1126,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1125,6 +1162,9 @@ packages: engines: {node: '>=6'} hasBin: true + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1132,6 +1172,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -1207,6 +1250,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1218,6 +1264,11 @@ packages: magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + mammoth@1.11.0: + resolution: {integrity: sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==} + engines: {node: '>=12.0.0'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1271,6 +1322,9 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1283,6 +1337,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1291,6 +1348,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1317,6 +1378,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -1357,6 +1421,9 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1373,6 +1440,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1388,6 +1458,9 @@ packages: set-cookie-parser@2.7.1: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1400,6 +1473,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1449,6 +1528,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + undici-types@7.14.0: resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} @@ -1461,6 +1543,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite@7.1.10: resolution: {integrity: sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1510,6 +1595,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1521,6 +1610,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.8: + resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -2089,6 +2196,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@xmldom/xmldom@0.8.11': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -2106,6 +2215,10 @@ snapshots: dependencies: color-convert: 2.0.1 + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} asynckit@0.4.0: {} @@ -2130,8 +2243,12 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.8.16: {} + bluebird@3.4.7: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2187,6 +2304,8 @@ snapshots: cookie@1.0.2: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2205,6 +2324,12 @@ snapshots: detect-libc@2.1.2: {} + dingbat-to-unicode@1.0.1: {} + + duck@0.1.12: + dependencies: + underscore: 1.13.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2456,6 +2581,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -2463,6 +2590,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -2471,6 +2600,8 @@ snapshots: is-number@7.0.0: {} + isarray@1.0.0: {} + isexe@2.0.0: {} jiti@2.6.1: {} @@ -2491,6 +2622,13 @@ snapshots: json5@2.2.3: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2500,6 +2638,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-darwin-arm64@1.30.1: optional: true @@ -2551,6 +2693,12 @@ snapshots: lodash.merge@4.6.2: {} + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.7 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2563,6 +2711,19 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mammoth@1.11.0: + dependencies: + '@xmldom/xmldom': 0.8.11 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.7 + xmlbuilder: 10.1.1 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -2602,6 +2763,8 @@ snapshots: normalize-range@0.1.2: {} + option@0.2.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2619,12 +2782,16 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} picocolors@1.1.1: {} @@ -2643,6 +2810,8 @@ snapshots: prelude-ls@1.2.1: {} + process-nextick-args@2.0.1: {} + proxy-from-env@1.1.0: {} punycode@2.3.1: {} @@ -2672,6 +2841,16 @@ snapshots: react@19.2.0: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + resolve-from@4.0.0: {} reusify@1.1.0: {} @@ -2708,6 +2887,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -2716,6 +2897,8 @@ snapshots: set-cookie-parser@2.7.1: {} + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2724,6 +2907,12 @@ snapshots: source-map-js@1.2.1: {} + sprintf-js@1.0.3: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -2772,6 +2961,8 @@ snapshots: typescript@5.9.3: {} + underscore@1.13.7: {} + undici-types@7.14.0: {} update-browserslist-db@1.1.3(browserslist@4.26.3): @@ -2784,6 +2975,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + vite@7.1.10(@types/node@24.8.0)(jiti@2.6.1)(lightningcss@1.30.1): dependencies: esbuild: 0.25.11 @@ -2804,8 +2997,15 @@ snapshots: word-wrap@1.2.5: {} + xmlbuilder@10.1.1: {} + yallist@3.1.1: {} yallist@5.0.0: {} yocto-queue@0.1.0: {} + + zustand@5.0.8(@types/react@19.2.2)(react@19.2.0): + optionalDependencies: + '@types/react': 19.2.2 + react: 19.2.0 diff --git a/public/video/5_alpha channel.webm b/public/video/5_alpha channel.webm new file mode 100644 index 0000000..79c4ca1 Binary files /dev/null and b/public/video/5_alpha channel.webm differ diff --git a/src/assets/images/components/引导页背景.png b/src/assets/images/components/引导页背景.png new file mode 100644 index 0000000..4f28d27 Binary files /dev/null and b/src/assets/images/components/引导页背景.png differ diff --git a/src/assets/images/components/按钮-跳过.png b/src/assets/images/components/按钮-跳过.png new file mode 100644 index 0000000..4373353 Binary files /dev/null and b/src/assets/images/components/按钮-跳过.png differ diff --git a/src/assets/images/components/框.png b/src/assets/images/components/框.png new file mode 100644 index 0000000..58094fb Binary files /dev/null and b/src/assets/images/components/框.png differ diff --git a/src/assets/images/content/图文.png b/src/assets/images/content/图文.png index 79fe02e..7613e42 100644 Binary files a/src/assets/images/content/图文.png and b/src/assets/images/content/图文.png differ diff --git a/src/assets/images/guide/引导页背景.png b/src/assets/images/guide/引导页背景.png new file mode 100644 index 0000000..4f28d27 Binary files /dev/null and b/src/assets/images/guide/引导页背景.png differ diff --git a/src/assets/images/guide/框.png b/src/assets/images/guide/框.png new file mode 100644 index 0000000..58094fb Binary files /dev/null and b/src/assets/images/guide/框.png differ diff --git a/src/assets/images/menu/卢航-未选中.png b/src/assets/images/menu/卢航-未选中.png index d862952..48ae8e3 100644 Binary files a/src/assets/images/menu/卢航-未选中.png and b/src/assets/images/menu/卢航-未选中.png differ diff --git a/src/assets/images/publicity/发言底板.png b/src/assets/images/publicity/发言底板.png new file mode 100644 index 0000000..4ea6a04 Binary files /dev/null and b/src/assets/images/publicity/发言底板.png differ diff --git a/src/components/BackgroundImage.tsx b/src/components/BackgroundImage.tsx index d11fdcd..4b48242 100644 --- a/src/components/BackgroundImage.tsx +++ b/src/components/BackgroundImage.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { clsx } from "clsx"; -interface BackgroundImageProps { +interface BackgroundImageProps extends React.HTMLAttributes { src: string; alt?: string; className?: string; @@ -14,12 +14,12 @@ interface BackgroundImageProps { fallback?: string; onLoad?: () => void; onError?: () => void; - onClick?: () => void; children?: React.ReactNode; overlay?: boolean; overlayColor?: string; overlayOpacity?: number; overflowHidden?: boolean; + style?: React.CSSProperties; } export default function BackgroundImage({ @@ -35,12 +35,13 @@ export default function BackgroundImage({ fallback, onLoad, onError, - onClick, children, overlay = false, overlayColor = "rgba(0, 0, 0, 0.5)", overlayOpacity = 0.5, overflowHidden = true, + style, + ...restProps }: BackgroundImageProps) { const [imageSrc, setImageSrc] = useState(src); const [isLoading, setIsLoading] = useState(true); @@ -125,6 +126,7 @@ export default function BackgroundImage({ backgroundPosition, backgroundRepeat, position: "relative" as const, + ...style, }; const overlayStyle = { @@ -141,7 +143,7 @@ export default function BackgroundImage({ className )} style={backgroundStyle} - onClick={onClick} + {...restProps} > {/* 加载占位符 */} {isLoading && !placeholder && ( diff --git a/src/components/Image.tsx b/src/components/Image.tsx index 13f78b6..1aa483e 100644 --- a/src/components/Image.tsx +++ b/src/components/Image.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { clsx } from "clsx"; -interface ImageProps { +interface ImageProps extends React.HTMLAttributes { style?: React.CSSProperties; src: string; children?: React.ReactNode; @@ -16,7 +16,6 @@ interface ImageProps { fallback?: string; onLoad?: () => void; onError?: () => void; - onClick?: () => void; draggable?: boolean; quality?: number; blur?: boolean; @@ -38,11 +37,11 @@ export default function Image({ fallback, onLoad, onError, - onClick, draggable = false, quality: _quality = 80, blur = false, button = false, + ...restProps }: ImageProps) { const [imageSrc, setImageSrc] = useState(src); const [isLoading, setIsLoading] = useState(true); @@ -116,7 +115,7 @@ export default function Image({ className )} style={{ width, height, ...style }} - onClick={onClick} + {...restProps} > {/* 加载占位符 */} {isLoading && placeholder && ( diff --git a/src/data/activities.ts b/src/data/activities.ts index 58d0e5b..f110903 100644 --- a/src/data/activities.ts +++ b/src/data/activities.ts @@ -49,7 +49,7 @@ export const skillTrainingActivities: Activity[] = [ date: "2024年11月14日", docPath: "/word/【11月15日】工作室活动情况记录.docx", }, -]; +].reverse(); //交流与展示活动 export const exchangeAndShowActivities: Activity[] = [ @@ -69,7 +69,7 @@ export const exchangeAndShowActivities: Activity[] = [ { id: 3, name: "与桂同心育新农,山海携手促共富", - date: "2025年", + date: "2025年08月23日", docPath: "/word/与桂同心育新农,山海携手促共富.docx", }, { @@ -86,4 +86,4 @@ export const exchangeAndShowActivities: Activity[] = [ docPath: "/word/政校携手,口岸直播助侗乡——南湖学子浦江口岸助农实践出新彩.docx", }, -]; +].reverse(); diff --git a/src/hooks/USETOUCH_USAGE.md b/src/hooks/USETOUCH_USAGE.md new file mode 100644 index 0000000..ef33a89 --- /dev/null +++ b/src/hooks/USETOUCH_USAGE.md @@ -0,0 +1,428 @@ +# useTouch Hook 使用文档 + +## 概述 + +`useTouch` 是一个灵活强大的触摸手势处理 Hook,支持多种手势识别,包括: + +- 点击 (Tap) +- 双击 (Double Tap) +- 长按 (Long Press) +- 滑动 (Swipe) - 支持四个方向 +- 触摸移动追踪 + +同时支持触摸屏和鼠标操作,适用于移动端和桌面端。 + +## 基础用法 + +### 1. 简单点击 + +```tsx +import { useTouch } from "@/hooks"; + +function MyComponent() { + const { handlers } = useTouch({ + onTap: () => { + console.log("点击了"); + }, + }); + + return
点击我
; +} +``` + +### 2. 滑动手势 + +```tsx +const { handlers } = useTouch({ + onSwipeLeft: () => console.log("向左滑动"), + onSwipeRight: () => console.log("向右滑动"), + onSwipeUp: () => console.log("向上滑动"), + onSwipeDown: () => console.log("向下滑动"), +}); + +return
滑动区域
; +``` + +### 3. 双击 + +```tsx +const { handlers } = useTouch({ + onDoubleTap: () => { + console.log("双击触发"); + }, +}); + +return
双击我
; +``` + +### 4. 长按 + +```tsx +const { handlers } = useTouch({ + onLongPress: () => { + console.log("长按触发"); + }, + longPressDelay: 500, // 500ms 触发长按 +}); + +return
长按我
; +``` + +## 高级用法 + +### 获取触摸数据 + +```tsx +const { handlers, touchData, isTouching } = useTouch({ + onTouchMove: (data) => { + console.log("移动距离:", data.deltaX, data.deltaY); + console.log("移动速度:", data.velocity); + console.log("移动方向:", data.direction); + }, +}); + +return ( +
+ {isTouching && touchData && ( +
+ 移动: {touchData.deltaX.toFixed(0)}px, {touchData.deltaY.toFixed(0)}px +
+ )} +
+); +``` + +### 图片查看器示例 + +```tsx +function ImageViewer() { + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const { handlers } = useTouch({ + onDoubleTap: () => { + // 双击放大/还原 + setScale(scale === 1 ? 2 : 1); + }, + onSwipe: (data) => { + // 滑动移动图片 + if (scale > 1) { + setPosition((prev) => ({ + x: prev.x + data.deltaX, + y: prev.y + data.deltaY, + })); + } + }, + swipeThreshold: 5, + }); + + return ( +
+ +
+ ); +} +``` + +### 卡片滑动删除 + +```tsx +function SwipeableCard() { + const [offset, setOffset] = useState(0); + + const { handlers } = useTouch({ + onTouchMove: (data) => { + if (data.direction === "left" || data.direction === "right") { + setOffset(data.deltaX); + } + }, + onTouchEnd: (data) => { + if (Math.abs(data.deltaX) > 100) { + // 滑动超过 100px,执行删除 + onDelete(); + } else { + // 弹回原位 + setOffset(0); + } + }, + }); + + return ( +
+
卡片内容
+
+ ); +} +``` + +### 下拉刷新 + +```tsx +function PullToRefresh() { + const [pullDistance, setPullDistance] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); + + const { handlers } = useTouch({ + onTouchMove: (data) => { + // 只在向下滑动且在顶部时触发 + if (data.direction === "down" && window.scrollY === 0) { + setPullDistance(Math.min(data.deltaY, 80)); + } + }, + onTouchEnd: (data) => { + if (pullDistance > 50) { + setIsRefreshing(true); + // 执行刷新逻辑 + refresh().then(() => { + setIsRefreshing(false); + setPullDistance(0); + }); + } else { + setPullDistance(0); + } + }, + }); + + return ( +
+
+ {isRefreshing + ? "刷新中..." + : pullDistance > 50 + ? "松开刷新" + : "下拉刷新"} +
+
内容区域
+
+ ); +} +``` + +### 轮播图 + +```tsx +function Carousel({ images }: { images: string[] }) { + const [currentIndex, setCurrentIndex] = useState(0); + + const { handlers } = useTouch({ + onSwipeLeft: () => { + setCurrentIndex((prev) => Math.min(prev + 1, images.length - 1)); + }, + onSwipeRight: () => { + setCurrentIndex((prev) => Math.max(prev - 1, 0)); + }, + swipeThreshold: 50, + swipeVelocityThreshold: 0.3, + }); + + return ( +
+
+ {images.map((img, i) => ( + + ))} +
+
+ ); +} +``` + +## 配置选项 + +```typescript +interface UseTouchOptions { + // 基础配置 + enabled?: boolean; // 是否启用,默认 true + preventDefault?: boolean; // 是否阻止默认行为,默认 false + stopPropagation?: boolean; // 是否阻止事件冒泡,默认 false + + // 滑动配置 + swipeThreshold?: number; // 滑动阈值(px),默认 10 + swipeVelocityThreshold?: number; // 滑动速度阈值(px/ms),默认 0.5 + + // 长按配置 + longPressDelay?: number; // 长按延迟(ms),默认 500 + longPressMoveThreshold?: number; // 长按允许移动距离(px),默认 10 + + // 双击配置 + doubleTapDelay?: number; // 双击间隔(ms),默认 300 + doubleTapMoveThreshold?: number; // 双击允许移动距离(px),默认 10 + + // 事件回调 + onTouchStart?: (data: TouchEventData) => void; + onTouchMove?: (data: TouchEventData) => void; + onTouchEnd?: (data: TouchEventData) => void; + onTap?: (data: TouchEventData) => void; + onDoubleTap?: (data: TouchEventData) => void; + onLongPress?: (data: TouchEventData) => void; + onSwipe?: (data: TouchEventData) => void; + onSwipeLeft?: (data: TouchEventData) => void; + onSwipeRight?: (data: TouchEventData) => void; + onSwipeUp?: (data: TouchEventData) => void; + onSwipeDown?: (data: TouchEventData) => void; +} +``` + +## 触摸数据 + +所有回调函数都会接收到 `TouchEventData` 对象: + +```typescript +interface TouchEventData { + startPoint: TouchPoint; // 起始位置 { x, y, timestamp } + currentPoint: TouchPoint; // 当前位置 { x, y, timestamp } + deltaX: number; // X 轴移动距离 + deltaY: number; // Y 轴移动距离 + distance: number; // 移动的总距离 + velocity: number; // 移动速度(像素/毫秒) + direction: SwipeDirection; // 移动方向: 'left' | 'right' | 'up' | 'down' | null + event: React.TouchEvent | React.MouseEvent; // 原始事件 +} +``` + +## 返回值 + +```typescript +interface UseTouchReturn { + isTouching: boolean; // 是否正在触摸 + touchData: TouchEventData | null; // 当前触摸数据 + handlers: { + onTouchStart: (e: React.TouchEvent) => void; + onTouchMove: (e: React.TouchEvent) => void; + onTouchEnd: (e: React.TouchEvent) => void; + onMouseDown: (e: React.MouseEvent) => void; + }; +} +``` + +## 注意事项 + +1. **事件处理器自动包含鼠标和触摸事件**,无需分别绑定 +2. **长按和滑动互斥**:如果移动距离超过阈值,会取消长按 +3. **双击优先级高于单击**:在双击延迟内的第二次点击会触发双击而非单击 +4. **性能优化**:使用 `useCallback` 避免不必要的重渲染 +5. **跨平台支持**:同时支持移动端触摸和桌面端鼠标操作 + +## 最佳实践 + +### 1. 避免与滚动冲突 + +```tsx +const { handlers } = useTouch({ + onSwipeLeft: handleSwipe, + preventDefault: false, // 不阻止默认行为,允许页面滚动 +}); +``` + +### 2. 性能优化 + +```tsx +// 使用 useMemo 缓存配置 +const touchOptions = useMemo( + () => ({ + onSwipe: handleSwipe, + swipeThreshold: 50, + }), + [handleSwipe] +); + +const { handlers } = useTouch(touchOptions); +``` + +### 3. 条件禁用 + +```tsx +const { handlers } = useTouch({ + enabled: !isLoading, // 加载时禁用触摸 + onTap: handleTap, +}); +``` + +## 完整示例 + +```tsx +import { useTouch } from "@/hooks"; +import { useState } from "react"; + +function TouchDemo() { + const [log, setLog] = useState([]); + + const addLog = (message: string) => { + setLog((prev) => [...prev, message].slice(-5)); + }; + + const { handlers, isTouching, touchData } = useTouch({ + onTap: () => addLog("点击"), + onDoubleTap: () => addLog("双击"), + onLongPress: () => addLog("长按"), + onSwipeLeft: () => addLog("向左滑动"), + onSwipeRight: () => addLog("向右滑动"), + onSwipeUp: () => addLog("向上滑动"), + onSwipeDown: () => addLog("向下滑动"), + }); + + return ( +
+
+ 触摸测试区域 + {touchData && ( +
+ Δx: {touchData.deltaX.toFixed(0)} + Δy: {touchData.deltaY.toFixed(0)} +
+ )} +
+ +
+

操作日志:

+ {log.map((msg, i) => ( +
{msg}
+ ))} +
+
+ ); +} +``` + +## 总结 + +`useTouch` Hook 提供了: + +- ✅ 完整的手势识别(点击、双击、长按、滑动) +- ✅ 灵活的配置选项 +- ✅ 详细的触摸数据 +- ✅ 跨平台支持(触摸 + 鼠标) +- ✅ TypeScript 类型安全 +- ✅ 性能优化 + +适用于各种触摸交互场景! diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..6d197ab --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,9 @@ +export { useDragAndDrop } from "./useDragAndDrop"; +export { usePageScale } from "./usePageScale"; +export { useTouch } from "./useTouch"; +export type { + UseTouchOptions, + UseTouchReturn, + TouchEventData, + SwipeDirection, +} from "./useTouch"; diff --git a/src/hooks/useTouch.ts b/src/hooks/useTouch.ts new file mode 100644 index 0000000..731282c --- /dev/null +++ b/src/hooks/useTouch.ts @@ -0,0 +1,485 @@ +import { useRef, useCallback, useState } from "react"; + +/** + * 触摸点信息 + */ +interface TouchPoint { + x: number; + y: number; + timestamp: number; +} + +/** + * 滑动方向 + */ +export type SwipeDirection = "left" | "right" | "up" | "down"; + +/** + * 手势事件回调参数 + */ +export interface TouchEventData { + /** 起始位置 */ + startPoint: TouchPoint; + /** 当前位置 */ + currentPoint: TouchPoint; + /** 移动距离 X */ + deltaX: number; + /** 移动距离 Y */ + deltaY: number; + /** 移动的总距离 */ + distance: number; + /** 移动速度(像素/毫秒) */ + velocity: number; + /** 移动方向 */ + direction: SwipeDirection | null; + /** 原始事件 */ + event: React.TouchEvent | React.MouseEvent; +} + +/** + * useTouch 配置选项 + */ +export interface UseTouchOptions { + /** 是否启用,默认 true */ + enabled?: boolean; + + // ========== 滑动配置 ========== + /** 触发滑动的最小距离(像素),默认 10 */ + swipeThreshold?: number; + /** 触发快速滑动的最小速度(像素/毫秒),默认 0.5 */ + swipeVelocityThreshold?: number; + + // ========== 长按配置 ========== + /** 触发长按的时间(毫秒),默认 500 */ + longPressDelay?: number; + /** 长按时允许的最大移动距离(像素),默认 10 */ + longPressMoveThreshold?: number; + + // ========== 双击配置 ========== + /** 双击的最大时间间隔(毫秒),默认 300 */ + doubleTapDelay?: number; + /** 双击的最大移动距离(像素),默认 10 */ + doubleTapMoveThreshold?: number; + + // ========== 事件回调 ========== + /** 触摸开始 */ + onTouchStart?: (data: TouchEventData) => void; + /** 触摸移动 */ + onTouchMove?: (data: TouchEventData) => void; + /** 触摸结束 */ + onTouchEnd?: (data: TouchEventData) => void; + /** 点击 */ + onTap?: (data: TouchEventData) => void; + /** 双击 */ + onDoubleTap?: (data: TouchEventData) => void; + /** 长按 */ + onLongPress?: (data: TouchEventData) => void; + /** 滑动 */ + onSwipe?: (data: TouchEventData) => void; + /** 向左滑动 */ + onSwipeLeft?: (data: TouchEventData) => void; + /** 向右滑动 */ + onSwipeRight?: (data: TouchEventData) => void; + /** 向上滑动 */ + onSwipeUp?: (data: TouchEventData) => void; + /** 向下滑动 */ + onSwipeDown?: (data: TouchEventData) => void; + + // ========== 其他配置 ========== + /** 是否阻止默认行为,默认 false */ + preventDefault?: boolean; + /** 是否阻止事件冒泡,默认 false */ + stopPropagation?: boolean; +} + +/** + * useTouch 返回值 + */ +export interface UseTouchReturn { + /** 是否正在触摸 */ + isTouching: boolean; + /** 触摸事件处理器 */ + handlers: { + onTouchStart: (e: React.TouchEvent) => void; + onTouchMove: (e: React.TouchEvent) => void; + onTouchEnd: (e: React.TouchEvent) => void; + onMouseDown: (e: React.MouseEvent) => void; + }; + /** 当前触摸数据 */ + touchData: TouchEventData | null; +} + +/** + * 计算两点之间的距离 + */ +function getDistance(p1: TouchPoint, p2: TouchPoint): number { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + return Math.sqrt(dx * dx + dy * dy); +} + +/** + * 计算滑动方向 + */ +function getDirection(deltaX: number, deltaY: number): SwipeDirection | null { + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + + if (absDeltaX < 10 && absDeltaY < 10) { + return null; + } + + if (absDeltaX > absDeltaY) { + return deltaX > 0 ? "right" : "left"; + } else { + return deltaY > 0 ? "down" : "up"; + } +} + +/** + * 灵活的触摸手势处理 Hook + * + * @example + * ```tsx + * const { handlers, isTouching } = useTouch({ + * onSwipeLeft: () => console.log('向左滑动'), + * onSwipeRight: () => console.log('向右滑动'), + * onTap: () => console.log('点击'), + * onDoubleTap: () => console.log('双击'), + * onLongPress: () => console.log('长按'), + * }); + * + * return
可触摸区域
; + * ``` + */ +export function useTouch(options: UseTouchOptions = {}): UseTouchReturn { + const { + enabled = true, + swipeThreshold = 10, + swipeVelocityThreshold = 0.5, + longPressDelay = 500, + longPressMoveThreshold = 10, + doubleTapDelay = 300, + doubleTapMoveThreshold = 10, + onTouchStart, + onTouchMove, + onTouchEnd, + onTap, + onDoubleTap, + onLongPress, + onSwipe, + onSwipeLeft, + onSwipeRight, + onSwipeUp, + onSwipeDown, + preventDefault = false, + stopPropagation = false, + } = options; + + // 状态 + const [isTouching, setIsTouching] = useState(false); + const [touchData, setTouchData] = useState(null); + + // 引用 + const startPointRef = useRef(null); + const currentPointRef = useRef(null); + const lastTapTimeRef = useRef(0); + const longPressTimerRef = useRef(null); + const isLongPressTriggeredRef = useRef(false); + const isMouseDownRef = useRef(false); + + /** + * 创建触摸点 + */ + const createTouchPoint = useCallback( + (x: number, y: number): TouchPoint => ({ + x, + y, + timestamp: Date.now(), + }), + [] + ); + + /** + * 创建触摸事件数据 + */ + const createTouchEventData = useCallback( + ( + startPoint: TouchPoint, + currentPoint: TouchPoint, + event: React.TouchEvent | React.MouseEvent + ): TouchEventData => { + const deltaX = currentPoint.x - startPoint.x; + const deltaY = currentPoint.y - startPoint.y; + const distance = getDistance(startPoint, currentPoint); + const deltaTime = currentPoint.timestamp - startPoint.timestamp; + const velocity = deltaTime > 0 ? distance / deltaTime : 0; + const direction = getDirection(deltaX, deltaY); + + return { + startPoint, + currentPoint, + deltaX, + deltaY, + distance, + velocity, + direction, + event, + }; + }, + [] + ); + + /** + * 清除长按定时器 + */ + const clearLongPressTimer = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + }, []); + + /** + * 处理触摸开始 + */ + const handleStart = useCallback( + (x: number, y: number, event: React.TouchEvent | React.MouseEvent) => { + if (!enabled) return; + + if (preventDefault) event.preventDefault(); + if (stopPropagation) event.stopPropagation(); + + const point = createTouchPoint(x, y); + startPointRef.current = point; + currentPointRef.current = point; + isLongPressTriggeredRef.current = false; + + setIsTouching(true); + + const data = createTouchEventData(point, point, event); + setTouchData(data); + onTouchStart?.(data); + + // 设置长按定时器 + if (onLongPress) { + clearLongPressTimer(); + longPressTimerRef.current = setTimeout(() => { + if (startPointRef.current && currentPointRef.current) { + const distance = getDistance( + startPointRef.current, + currentPointRef.current + ); + if (distance < longPressMoveThreshold) { + isLongPressTriggeredRef.current = true; + const longPressData = createTouchEventData( + startPointRef.current, + currentPointRef.current, + event + ); + onLongPress(longPressData); + } + } + }, longPressDelay); + } + }, + [ + enabled, + preventDefault, + stopPropagation, + createTouchPoint, + createTouchEventData, + onTouchStart, + onLongPress, + longPressDelay, + longPressMoveThreshold, + clearLongPressTimer, + ] + ); + + /** + * 处理触摸移动 + */ + const handleMove = useCallback( + (x: number, y: number, event: React.TouchEvent | React.MouseEvent) => { + if (!enabled || !startPointRef.current) return; + + if (preventDefault) event.preventDefault(); + if (stopPropagation) event.stopPropagation(); + + const point = createTouchPoint(x, y); + currentPointRef.current = point; + + const data = createTouchEventData(startPointRef.current, point, event); + setTouchData(data); + onTouchMove?.(data); + + // 如果移动距离超过阈值,取消长按 + if (data.distance > longPressMoveThreshold) { + clearLongPressTimer(); + } + }, + [ + enabled, + preventDefault, + stopPropagation, + createTouchPoint, + createTouchEventData, + onTouchMove, + longPressMoveThreshold, + clearLongPressTimer, + ] + ); + + /** + * 处理触摸结束 + */ + const handleEnd = useCallback( + (event: React.TouchEvent | React.MouseEvent) => { + if (!enabled || !startPointRef.current || !currentPointRef.current) + return; + + if (preventDefault) event.preventDefault(); + if (stopPropagation) event.stopPropagation(); + + clearLongPressTimer(); + + const data = createTouchEventData( + startPointRef.current, + currentPointRef.current, + event + ); + onTouchEnd?.(data); + + // 判断手势类型 + const { distance, velocity, direction } = data; + + // 长按已触发,不处理其他手势 + if (isLongPressTriggeredRef.current) { + setIsTouching(false); + setTouchData(null); + startPointRef.current = null; + currentPointRef.current = null; + return; + } + + // 滑动手势 + if (distance > swipeThreshold || velocity > swipeVelocityThreshold) { + onSwipe?.(data); + if (direction === "left") onSwipeLeft?.(data); + if (direction === "right") onSwipeRight?.(data); + if (direction === "up") onSwipeUp?.(data); + if (direction === "down") onSwipeDown?.(data); + } + // 点击或双击 + else if (distance < doubleTapMoveThreshold) { + const now = Date.now(); + const timeSinceLastTap = now - lastTapTimeRef.current; + + if (timeSinceLastTap < doubleTapDelay && onDoubleTap) { + // 双击 + onDoubleTap(data); + lastTapTimeRef.current = 0; // 重置,避免三击触发两次双击 + } else { + // 单击 + onTap?.(data); + lastTapTimeRef.current = now; + } + } + + setIsTouching(false); + setTouchData(null); + startPointRef.current = null; + currentPointRef.current = null; + }, + [ + enabled, + preventDefault, + stopPropagation, + createTouchEventData, + onTouchEnd, + onSwipe, + onSwipeLeft, + onSwipeRight, + onSwipeUp, + onSwipeDown, + onTap, + onDoubleTap, + swipeThreshold, + swipeVelocityThreshold, + doubleTapMoveThreshold, + doubleTapDelay, + clearLongPressTimer, + ] + ); + + // 触摸事件处理器 + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length === 1) { + const touch = e.touches[0]; + handleStart(touch?.clientX || 0, touch?.clientY || 0, e); + } + }, + [handleStart] + ); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length === 1) { + const touch = e.touches[0]; + handleMove(touch?.clientX || 0, touch?.clientY || 0, e); + } + }, + [handleMove] + ); + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + handleEnd(e); + }, + [handleEnd] + ); + + // 鼠标事件处理器(桌面端支持) + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + isMouseDownRef.current = true; + handleStart(e.clientX, e.clientY, e); + + const handleMouseMove = (event: MouseEvent) => { + if (isMouseDownRef.current) { + handleMove( + event.clientX, + event.clientY, + e as unknown as React.MouseEvent + ); + } + }; + + const handleMouseUp = (event: MouseEvent) => { + if (isMouseDownRef.current) { + isMouseDownRef.current = false; + handleEnd(e as unknown as React.MouseEvent); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + } + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + }, + [handleStart, handleMove, handleEnd] + ); + + return { + isTouching, + handlers: { + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + onMouseDown: handleMouseDown, + }, + touchData, + }; +} diff --git a/src/pages/Content.tsx b/src/pages/Content.tsx index f6b1166..070f056 100644 --- a/src/pages/Content.tsx +++ b/src/pages/Content.tsx @@ -206,10 +206,10 @@ const TabContent = ({ - - @@ -220,7 +220,7 @@ const TabContent = ({ key={activity.id} onClick={() => handleActivityClick(activity)} className={clsx( - "border-b border-gray-200 cursor-pointer transition-all duration-200", + "border-b border-gray-200 cursor-pointer transition-all duration-200 text-3xl", "hover:bg-[#82B2D6]/10 hover:shadow-md", index % 2 === 0 ? "bg-white" : "bg-gray-50" )} @@ -259,10 +259,10 @@ const TabContent = ({
+ 活动名称 + 时间
- - @@ -276,7 +276,7 @@ const TabContent = ({ handleActivitiesClick(activity) } className={clsx( - "border-b border-gray-200 cursor-pointer transition-all duration-200", + "border-b border-gray-200 cursor-pointer transition-all duration-200 text-3xl", "hover:bg-[#82B2D6]/10 hover:shadow-md", index % 2 === 0 ? "bg-white" : "bg-gray-50" )} diff --git a/src/pages/Guide.tsx b/src/pages/Guide.tsx new file mode 100644 index 0000000..39d3fdb --- /dev/null +++ b/src/pages/Guide.tsx @@ -0,0 +1,77 @@ +import { useNavigate } from "react-router-dom"; +import { BackgroundImage, Image } from "@/components"; +import { getStaticImage, getPublicPath } from "@/utils"; +import { useState, useRef, useEffect } from "react"; + +export default function Guide() { + const navigate = useNavigate(); + const [showVideo, setShowVideo] = useState(true); + const videoRef = useRef(null); + + // 监听视频播放完成 + const handleVideoEnd = () => { + setShowVideo(false); + }; + + // 跳过引导,直接进入宣传页 + const handleSkip = () => { + navigate("/publicity"); + }; + + return ( + + {/* 跳过按钮 */} + + + {/* 标题 */} + + + {/* 引导视频 - 仅在首次显示 */} + { +
+
+ } + + {/* 引导内容区域 */} + { +
+ {/* 发言底板 */} + +

+ 各位朋友好!我是曹君,智慧商旅系主任、网络营销与直播电商专业带头人。我的名师工作室聚焦产教融合,紧扣新兴行业需求,对接虹口北外滩发展战略。 + 带教以实战为核心,依托全国行业资源,让学员深入企业、乡村参与直播全流程;同时兼顾实践与教学能力双提升,致力培养复合型数字技能人才,期待与大家共成长! +

+
+
+ } +
+ ); +} diff --git a/src/pages/Menu.tsx b/src/pages/Menu.tsx index 7e18895..63ac821 100644 --- a/src/pages/Menu.tsx +++ b/src/pages/Menu.tsx @@ -1,8 +1,8 @@ import { useNavigate } from "react-router-dom"; import { BackgroundImage, Image, Alert } from "@/components"; import { getStaticImage } from "@/utils"; -import { useState } from "react"; -import { IframeModal } from "@/components/Modal"; +import { useState, Fragment, useRef, useEffect } from "react"; +import { useTouch } from "@/hooks"; /** * 卢航-未选中.png @@ -16,8 +16,8 @@ const teachers = [ avatar: getStaticImage("menu/卢航-未选中.png"), selectedAvatar: getStaticImage("menu/卢航-选中.png"), position: { - top: "20%", - left: "12%", + top: "20.5%", + left: "15%", }, }, { @@ -36,8 +36,8 @@ const teachers = [ avatar: getStaticImage("menu/龚丹-未选中.png"), selectedAvatar: getStaticImage("menu/龚丹-选中.png"), position: { - top: "20%", - left: "65%", + top: "20.5%", + left: "63%", }, }, ]; @@ -45,9 +45,73 @@ const teachers = [ export default function Home() { const navigate = useNavigate(); const [selectedTeacherId, setSelectedTeacherId] = useState(0); - const [isModalOpen, setIsModalOpen] = useState(false); const [showTipModal, setShowTipModal] = useState(false); + // 旋转相关状态 + const [rotation, setRotation] = useState(0); + const [isAutoRotating, setIsAutoRotating] = useState(true); + const autoRotateTimerRef = useRef(null); + const resumeTimerRef = useRef(null); + const startRotationRef = useRef(0); + + // 自动旋转效果 + useEffect(() => { + if (isAutoRotating) { + const animate = () => { + setRotation((prev) => prev + 0.2); + autoRotateTimerRef.current = requestAnimationFrame(animate); + }; + autoRotateTimerRef.current = requestAnimationFrame(animate); + } + + return () => { + if (autoRotateTimerRef.current !== null) { + cancelAnimationFrame(autoRotateTimerRef.current); + autoRotateTimerRef.current = null; + } + }; + }, [isAutoRotating]); + + // 组件卸载时清理定时器 + useEffect(() => { + return () => { + if (autoRotateTimerRef.current !== null) { + cancelAnimationFrame(autoRotateTimerRef.current); + } + if (resumeTimerRef.current !== null) { + clearTimeout(resumeTimerRef.current); + } + }; + }, []); + + // 使用 useTouch 处理触摸手势 + const { handlers } = useTouch({ + onTouchStart: () => { + // 停止自动旋转 + setIsAutoRotating(false); + + // 清除恢复定时器 + if (resumeTimerRef.current) { + clearTimeout(resumeTimerRef.current); + resumeTimerRef.current = null; + } + + // 记录开始时的旋转角度 + startRotationRef.current = rotation; + }, + onTouchMove: (data) => { + // 根据水平移动距离计算旋转角度(灵敏度 0.5) + const deltaRotation = data.deltaX * 0.5; + setRotation(startRotationRef.current + deltaRotation); + }, + onTouchEnd: () => { + // 1秒后恢复自动旋转 + resumeTimerRef.current = setTimeout(() => { + setIsAutoRotating(true); + }, 1000); + }, + }); + const handleSelectTeacher = (teacherId: number) => { if (teacherId !== 2) { // 点击卢航或龚丹时显示提示 @@ -55,20 +119,7 @@ export default function Home() { return; } setSelectedTeacherId(teacherId); - navigate("/Publicity"); - }; - - const handleStartExperiment = () => { - console.log("点击开启实验"); - // navigate("/experiment"); - }; - - const handleAvatarClick = () => { - setIsModalOpen(true); - }; - - const handleCloseModal = () => { - setIsModalOpen(false); + navigate("/guide"); }; return ( @@ -92,36 +143,6 @@ export default function Home() { className="absolute bottom-0 left-1/2 transform -translate-x-1/2" /> - {/* 数字人头像 */} - {!isModalOpen && ( - <> -
- - - 点击我随时为你解答 - -
- - )} - - {/* 嵌入式客服对话框 */} - - {/* 提示弹窗 */} - {teachers.map((teacher) => ( - <> + {teachers.map((teacher, _index) => ( + handleSelectTeacher(teacher.id)} + onClick={(e) => { + e.stopPropagation(); + handleSelectTeacher(teacher.id); + }} className="absolute" style={{ top: @@ -157,7 +187,8 @@ export default function Home() { selectedTeacherId === teacher.id ? "36%" : teacher.position.left, - transition: "transform 0.2s ease, z-index 0.2s ease", + transform: `rotate(${-rotation}deg)`, + transition: "none", }} button={selectedTeacherId !== teacher.id} /> @@ -168,7 +199,7 @@ export default function Home() {
该名师工作室数字睿教正在筹划中
*/} - +
))} diff --git a/src/pages/Publicity.tsx b/src/pages/Publicity.tsx index a19151a..e633638 100644 --- a/src/pages/Publicity.tsx +++ b/src/pages/Publicity.tsx @@ -1,8 +1,9 @@ import { BackgroundImage, Image } from "@/components"; import { IframeModal } from "@/components/Modal"; -import { getStaticImage } from "@/utils"; -import { useState } from "react"; +import { getPublicPath, getStaticImage } from "@/utils"; +import { useState, useRef, useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useFirstVisitStore } from "@/store"; // 解答弹窗组件 interface DialogBoxProps { @@ -116,6 +117,33 @@ export default function PublicityPage() { const [selectedFunctionId, setSelectedFunctionId] = useState(0); const [isModalOpen, setIsModalOpen] = useState(false); + // 首次访问状态管理 + const { hasSeenIntro, setHasSeenIntro, isExpired, reset } = + useFirstVisitStore(); + const [showIntroVideo, setShowIntroVideo] = useState(false); + const videoRef = useRef(null); + + // 检查是否显示引导视频 + useEffect(() => { + // 如果存储已过期,重置状态 + if (isExpired()) { + reset(); + setShowIntroVideo(true); + return; + } + + if (!hasSeenIntro) { + setShowIntroVideo(true); + } + }, [hasSeenIntro, isExpired, reset]); + + // 监听视频播放完成 + const handleVideoEnd = () => { + setShowIntroVideo(false); + setHasSeenIntro(); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars const handleOpenDialog = () => { setIsDialogOpen(true); }; @@ -170,7 +198,7 @@ export default function PublicityPage() { className="absolute left-0 top-30" /> - {/* 数字人 */} + {/* 数字人 - 未播放引导视频时显示 */} {/* 数字人头像 */} - {!isModalOpen && ( + {/* {!isModalOpen && ( <>
- )} + )} */} {/* 提示 */} {/* */} {/* 解答弹窗 */} - + {/* */} {/* 嵌入式客服对话框 */} - + /> */} ); } diff --git a/src/router/index.tsx b/src/router/index.tsx index 69fe68b..3f0c9f1 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -4,6 +4,7 @@ import Menu from "@/pages/Menu"; import NotFound from "@/pages/NotFound"; import Publicity from "@/pages/Publicity"; import Content from "@/pages/Content"; +import Guide from "@/pages/Guide"; /** * 应用路由配置 * 使用 React Router v7 的 createHashRouter @@ -22,6 +23,10 @@ const router = createHashRouter([ path: "/menu", element: , }, + { + path: "/guide", + element: , + }, { path: "/publicity", element: , diff --git a/src/store/firstVisitStore.ts b/src/store/firstVisitStore.ts new file mode 100644 index 0000000..7ca8dc0 --- /dev/null +++ b/src/store/firstVisitStore.ts @@ -0,0 +1,39 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface FirstVisitState { + hasSeenIntro: boolean; + timestamp: number | null; // 存储时间戳 + setHasSeenIntro: () => void; + isExpired: () => boolean; // 检查是否过期 + reset: () => void; // 重置状态 +} + +// 存储有效期(毫秒),默认5分钟 +const STORAGE_EXPIRY_TIME = 5 * 60 * 1000; + +export const useFirstVisitStore = create()( + persist( + (set, get) => ({ + hasSeenIntro: false, + timestamp: null, + setHasSeenIntro: () => { + set({ hasSeenIntro: true, timestamp: Date.now() }); + }, + isExpired: () => { + const state = get(); + if (!state.timestamp) { + return false; + } + const now = Date.now(); + return now - state.timestamp > STORAGE_EXPIRY_TIME; + }, + reset: () => { + set({ hasSeenIntro: false, timestamp: null }); + }, + }), + { + name: "first-visit-storage", // 存储到localStorage的key + } + ) +); diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..5051ec2 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export * from "./firstVisitStore";
+ 活动名称 + 时间