diff --git a/package.json b/package.json index 9f30638..647042a 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,15 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "build": "tsc -b && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "predeploy": "pnpm build", + "deploy": "gh-pages -d dist" }, "dependencies": { "@chakra-ui/icons": "2.2.4", "@chakra-ui/react": "2.10.9", "@dnd-kit/core": "latest", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/utilities": "latest", "@emotion/react": "latest", "@emotion/styled": "latest", @@ -39,6 +42,7 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", + "gh-pages": "^6.3.0", "tsx": "^4.17.0", "typescript": "^5.4.5", "vite": "npm:rolldown-vite@latest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 704a7bc..dc96bc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@dnd-kit/core': specifier: latest version: 6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1) '@dnd-kit/utilities': specifier: latest version: 3.2.2(react@19.1.1) @@ -84,6 +87,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.7 version: 0.4.20(eslint@8.57.1) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 tsx: specifier: ^4.17.0 version: 4.19.4 @@ -208,6 +214,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + '@dnd-kit/utilities@3.2.2': resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} peerDependencies: @@ -1243,6 +1255,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1316,6 +1331,13 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1399,6 +1421,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1441,6 +1466,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1529,13 +1558,29 @@ packages: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1585,6 +1630,10 @@ packages: framesync@6.1.2: resolution: {integrity: sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==} + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1618,6 +1667,11 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1650,6 +1704,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1784,6 +1841,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1858,6 +1918,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1897,6 +1961,10 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -1985,14 +2053,26 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2051,6 +2131,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + postcss@8.5.4: resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==} engines: {node: ^10 || ^12 || >=14} @@ -2233,6 +2317,10 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -2309,6 +2397,10 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -2375,6 +2467,10 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -2416,6 +2512,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2708,6 +2808,13 @@ snapshots: react-dom: 19.1.1(react@19.1.1) tslib: 2.8.1 + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@dnd-kit/utilities': 3.2.2(react@19.1.1) + react: 19.1.1 + tslib: 2.8.1 + '@dnd-kit/utilities@3.2.2(react@19.1.1)': dependencies: react: 19.1.1 @@ -3609,6 +3716,8 @@ snapshots: assertion-error@2.0.1: {} + async@3.2.6: {} + asynckit@0.4.0: {} axios@1.11.0: @@ -3689,6 +3798,10 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@13.1.0: {} + + commondir@1.0.1: {} + concat-map@0.0.1: {} convert-source-map@1.9.0: {} @@ -3755,6 +3868,8 @@ snapshots: eastasianwidth@0.2.0: {} + email-addresses@5.0.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -3836,6 +3951,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): @@ -3948,12 +4065,31 @@ snapshots: dependencies: flat-cache: 3.2.0 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + find-root@1.1.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -4000,6 +4136,12 @@ snapshots: dependencies: tslib: 2.4.0 + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -4035,6 +4177,16 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.3 + globby: 11.1.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -4078,6 +4230,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} graphql@16.11.0: {} @@ -4187,6 +4341,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4243,6 +4403,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -4279,6 +4443,10 @@ snapshots: '@babel/types': 7.27.6 source-map-js: 1.2.1 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + make-dir@4.0.0: dependencies: semver: 7.7.2 @@ -4368,14 +4536,24 @@ snapshots: outvariant@1.4.3: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -4418,6 +4596,10 @@ snapshots: picomatch@4.0.3: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + postcss@8.5.4: dependencies: nanoid: 3.3.11 @@ -4606,6 +4788,8 @@ snapshots: scheduler@0.26.0: {} + semver@6.3.1: {} + semver@7.7.2: {} shebang-command@2.0.0: @@ -4668,6 +4852,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + stylis@4.2.0: {} supports-color@7.2.0: @@ -4719,6 +4907,10 @@ snapshots: dependencies: tldts: 7.0.12 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-api-utils@1.4.3(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -4748,6 +4940,8 @@ snapshots: undici-types@6.21.0: {} + universalify@2.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/src/App.tsx b/src/App.tsx index 664bf6d..6232830 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,13 @@ -import { ChakraProvider } from "@chakra-ui/react"; -import { ScheduleProvider } from "./ScheduleContext.tsx"; -import { ScheduleTables } from "./ScheduleTables.tsx"; -import ScheduleDndProvider from "./ScheduleDndProvider.tsx"; +import { ChakraProvider } from '@chakra-ui/react'; -function App() { +import { ScheduleProvider } from '@/contexts/schedule'; +import { ScheduleTables } from '@/components/schedule/ScheduleTables'; +function App() { return ( - - - + ); diff --git a/src/ScheduleContext.tsx b/src/ScheduleContext.tsx deleted file mode 100644 index 529f0dd..0000000 --- a/src/ScheduleContext.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { createContext, PropsWithChildren, useContext, useState } from "react"; -import { Schedule } from "./types.ts"; -import dummyScheduleMap from "./dummyScheduleMap.ts"; - -interface ScheduleContextType { - schedulesMap: Record; - setSchedulesMap: React.Dispatch>>; -} - -const ScheduleContext = createContext(undefined); - -export const useScheduleContext = () => { - const context = useContext(ScheduleContext); - if (context === undefined) { - throw new Error('useSchedule must be used within a ScheduleProvider'); - } - return context; -}; - -export const ScheduleProvider = ({ children }: PropsWithChildren) => { - const [schedulesMap, setSchedulesMap] = useState>(dummyScheduleMap); - - return ( - - {children} - - ); -}; diff --git a/src/ScheduleDndProvider.tsx b/src/ScheduleDndProvider.tsx deleted file mode 100644 index ca15f52..0000000 --- a/src/ScheduleDndProvider.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { DndContext, Modifier, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; -import { PropsWithChildren } from "react"; -import { CellSize, DAY_LABELS } from "./constants.ts"; -import { useScheduleContext } from "./ScheduleContext.tsx"; - -function createSnapModifier(): Modifier { - return ({ transform, containerNodeRect, draggingNodeRect }) => { - const containerTop = containerNodeRect?.top ?? 0; - const containerLeft = containerNodeRect?.left ?? 0; - const containerBottom = containerNodeRect?.bottom ?? 0; - const containerRight = containerNodeRect?.right ?? 0; - - const { top = 0, left = 0, bottom = 0, right = 0 } = draggingNodeRect ?? {}; - - const minX = containerLeft - left + 120 + 1; - const minY = containerTop - top + 40 + 1; - const maxX = containerRight - right; - const maxY = containerBottom - bottom; - - - return ({ - ...transform, - x: Math.min(Math.max(Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, minX), maxX), - y: Math.min(Math.max(Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, minY), maxY), - }) - }; -} - -const modifiers = [createSnapModifier()] - -export default function ScheduleDndProvider({ children }: PropsWithChildren) { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }) - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleDragEnd = (event: any) => { - const { active, delta } = event; - const { x, y } = delta; - const [tableId, index] = active.id.split(':'); - const schedule = schedulesMap[tableId][index]; - const nowDayIndex = DAY_LABELS.indexOf(schedule.day as typeof DAY_LABELS[number]) - const moveDayIndex = Math.floor(x / 80); - const moveTimeIndex = Math.floor(y / 30); - - setSchedulesMap({ - ...schedulesMap, - [tableId]: schedulesMap[tableId].map((targetSchedule, targetIndex) => { - if (targetIndex !== Number(index)) { - return { ...targetSchedule } - } - return { - ...targetSchedule, - day: DAY_LABELS[nowDayIndex + moveDayIndex], - range: targetSchedule.range.map(time => time + moveTimeIndex), - } - }) - }) - }; - - return ( - - {children} - - ); -} diff --git a/src/ScheduleTable.tsx b/src/ScheduleTable.tsx deleted file mode 100644 index ea17b6a..0000000 --- a/src/ScheduleTable.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - Box, - Button, - Flex, - Grid, - GridItem, - Popover, - PopoverArrow, - PopoverBody, - PopoverCloseButton, - PopoverContent, - PopoverTrigger, - Text, -} from "@chakra-ui/react"; -import { CellSize, DAY_LABELS, 분 } from "./constants.ts"; -import { Schedule } from "./types.ts"; -import { fill2, parseHnM } from "./utils.ts"; -import { useDndContext, useDraggable } from "@dnd-kit/core"; -import { CSS } from "@dnd-kit/utilities"; -import { ComponentProps, Fragment } from "react"; - -interface Props { - tableId: string; - schedules: Schedule[]; - onScheduleTimeClick?: (timeInfo: { day: string, time: number }) => void; - onDeleteButtonClick?: (timeInfo: { day: string, time: number }) => void; -} - -const TIMES = [ - ...Array(18) - .fill(0) - .map((v, k) => v + k * 30 * 분) - .map((v) => `${parseHnM(v)}~${parseHnM(v + 30 * 분)}`), - - ...Array(6) - .fill(18 * 30 * 분) - .map((v, k) => v + k * 55 * 분) - .map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`), -] as const; - -const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { - - const getColor = (lectureId: string): string => { - const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; - const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; - return colors[lectures.indexOf(lectureId) % colors.length]; - }; - - const dndContext = useDndContext(); - - const getActiveTableId = () => { - const activeId = dndContext.active?.id; - if (activeId) { - return String(activeId).split(":")[0]; - } - return null; - } - - const activeTableId = getActiveTableId(); - - return ( - - - - - 교시 - - - {DAY_LABELS.map((day) => ( - - - {day} - - - ))} - {TIMES.map((time, timeIndex) => ( - - 17 ? 'gray.200' : 'gray.100'} - > - - {fill2(timeIndex + 1)} ({time}) - - - {DAY_LABELS.map((day) => ( - 17 ? 'gray.100' : 'white'} - cursor="pointer" - _hover={{ bg: 'yellow.100' }} - onClick={() => onScheduleTimeClick?.({ day, time: timeIndex + 1 })} - /> - ))} - - ))} - - - {schedules.map((schedule, index) => ( - onDeleteButtonClick?.({ - day: schedule.day, - time: schedule.range[0], - })} - /> - ))} - - ); -}; - -const DraggableSchedule = ({ - id, - data, - bg, - onDeleteButtonClick -}: { id: string; data: Schedule } & ComponentProps & { - onDeleteButtonClick: () => void -}) => { - const { day, range, room, lecture } = data; - const { attributes, setNodeRef, listeners, transform } = useDraggable({ id }); - const leftIndex = DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]); - const topIndex = range[0] - 1; - const size = range.length; - - return ( - - - - {lecture.title} - {room} - - - event.stopPropagation()}> - - - - 강의를 삭제하시겠습니까? - - - - - ); -} - -export default ScheduleTable; diff --git a/src/ScheduleTables.tsx b/src/ScheduleTables.tsx deleted file mode 100644 index 44dbd7a..0000000 --- a/src/ScheduleTables.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react"; -import ScheduleTable from "./ScheduleTable.tsx"; -import { useScheduleContext } from "./ScheduleContext.tsx"; -import SearchDialog from "./SearchDialog.tsx"; -import { useState } from "react"; - -export const ScheduleTables = () => { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); - const [searchInfo, setSearchInfo] = useState<{ - tableId: string; - day?: string; - time?: number; - } | null>(null); - - const disabledRemoveButton = Object.keys(schedulesMap).length === 1; - - const duplicate = (targetId: string) => { - setSchedulesMap(prev => ({ - ...prev, - [`schedule-${Date.now()}`]: [...prev[targetId]] - })) - }; - - const remove = (targetId: string) => { - setSchedulesMap(prev => { - delete prev[targetId]; - return { ...prev }; - }) - }; - - return ( - <> - - {Object.entries(schedulesMap).map(([tableId, schedules], index) => ( - - - 시간표 {index + 1} - - - - - - - setSearchInfo({ tableId, ...timeInfo })} - onDeleteButtonClick={({ day, time }) => setSchedulesMap((prev) => ({ - ...prev, - [tableId]: prev[tableId].filter(schedule => schedule.day !== day || !schedule.range.includes(time)) - }))} - /> - - ))} - - setSearchInfo(null)}/> - - ); -} diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx deleted file mode 100644 index 593951f..0000000 --- a/src/SearchDialog.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { - Box, - Button, - Checkbox, - CheckboxGroup, - FormControl, - FormLabel, - HStack, - Input, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - Select, - Stack, - Table, - Tag, - TagCloseButton, - TagLabel, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - VStack, - Wrap, -} from "@chakra-ui/react"; -import { useScheduleContext } from "./ScheduleContext.tsx"; -import { Lecture } from "./types.ts"; -import { parseSchedule } from "./utils.ts"; -import axios from "axios"; -import { DAY_LABELS } from "./constants.ts"; - -interface Props { - searchInfo: { - tableId: string; - day?: string; - time?: number; - } | null; - onClose: () => void; -} - -interface SearchOption { - query?: string, - grades: number[], - days: string[], - times: number[], - majors: string[], - credits?: number, -} - -const TIME_SLOTS = [ - { id: 1, label: "09:00~09:30" }, - { id: 2, label: "09:30~10:00" }, - { id: 3, label: "10:00~10:30" }, - { id: 4, label: "10:30~11:00" }, - { id: 5, label: "11:00~11:30" }, - { id: 6, label: "11:30~12:00" }, - { id: 7, label: "12:00~12:30" }, - { id: 8, label: "12:30~13:00" }, - { id: 9, label: "13:00~13:30" }, - { id: 10, label: "13:30~14:00" }, - { id: 11, label: "14:00~14:30" }, - { id: 12, label: "14:30~15:00" }, - { id: 13, label: "15:00~15:30" }, - { id: 14, label: "15:30~16:00" }, - { id: 15, label: "16:00~16:30" }, - { id: 16, label: "16:30~17:00" }, - { id: 17, label: "17:00~17:30" }, - { id: 18, label: "17:30~18:00" }, - { id: 19, label: "18:00~18:50" }, - { id: 20, label: "18:55~19:45" }, - { id: 21, label: "19:50~20:40" }, - { id: 22, label: "20:45~21:35" }, - { id: 23, label: "21:40~22:30" }, - { id: 24, label: "22:35~23:25" }, -]; - -const PAGE_SIZE = 100; - -const fetchMajors = () => axios.get('/schedules-majors.json'); -const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json'); - -// TODO: 이 코드를 개선해서 API 호출을 최소화 해보세요 + Promise.all이 현재 잘못 사용되고 있습니다. 같이 개선해주세요. -const fetchAllLectures = async () => await Promise.all([ - (console.log('API Call 1', performance.now()), await fetchMajors()), - (console.log('API Call 2', performance.now()), await fetchLiberalArts()), - (console.log('API Call 3', performance.now()), await fetchMajors()), - (console.log('API Call 4', performance.now()), await fetchLiberalArts()), - (console.log('API Call 5', performance.now()), await fetchMajors()), - (console.log('API Call 6', performance.now()), await fetchLiberalArts()), -]); - -// TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. -const SearchDialog = ({ searchInfo, onClose }: Props) => { - const { setSchedulesMap } = useScheduleContext(); - - const loaderWrapperRef = useRef(null); - const loaderRef = useRef(null); - const [lectures, setLectures] = useState([]); - const [page, setPage] = useState(1); - const [searchOptions, setSearchOptions] = useState({ - query: '', - grades: [], - days: [], - times: [], - majors: [], - }); - - const getFilteredLectures = () => { - const { query = '', credits, grades, days, times, majors } = searchOptions; - return lectures - .filter(lecture => - lecture.title.toLowerCase().includes(query.toLowerCase()) || - lecture.id.toLowerCase().includes(query.toLowerCase()) - ) - .filter(lecture => grades.length === 0 || grades.includes(lecture.grade)) - .filter(lecture => majors.length === 0 || majors.includes(lecture.major)) - .filter(lecture => !credits || lecture.credits.startsWith(String(credits))) - .filter(lecture => { - if (days.length === 0) { - return true; - } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => days.includes(s.day)); - }) - .filter(lecture => { - if (times.length === 0) { - return true; - } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => s.range.some(time => times.includes(time))); - }); - } - - const filteredLectures = getFilteredLectures(); - const lastPage = Math.ceil(filteredLectures.length / PAGE_SIZE); - const visibleLectures = filteredLectures.slice(0, page * PAGE_SIZE); - const allMajors = [...new Set(lectures.map(lecture => lecture.major))]; - - const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => { - setPage(1); - setSearchOptions(({ ...searchOptions, [field]: value })); - loaderWrapperRef.current?.scrollTo(0, 0); - }; - - const addSchedule = (lecture: Lecture) => { - if (!searchInfo) return; - - const { tableId } = searchInfo; - - const schedules = parseSchedule(lecture.schedule).map(schedule => ({ - ...schedule, - lecture - })); - - setSchedulesMap(prev => ({ - ...prev, - [tableId]: [...prev[tableId], ...schedules] - })); - - onClose(); - }; - - useEffect(() => { - const start = performance.now(); - console.log('API 호출 시작: ', start) - fetchAllLectures().then(results => { - const end = performance.now(); - console.log('모든 API 호출 완료 ', end) - console.log('API 호출에 걸린 시간(ms): ', end - start) - setLectures(results.flatMap(result => result.data)); - }) - }, []); - - useEffect(() => { - const $loader = loaderRef.current; - const $loaderWrapper = loaderWrapperRef.current; - - if (!$loader || !$loaderWrapper) { - return; - } - - const observer = new IntersectionObserver( - entries => { - if (entries[0].isIntersecting) { - setPage(prevPage => Math.min(lastPage, prevPage + 1)); - } - }, - { threshold: 0, root: $loaderWrapper } - ); - - observer.observe($loader); - - return () => observer.unobserve($loader); - }, [lastPage]); - - useEffect(() => { - setSearchOptions(prev => ({ - ...prev, - days: searchInfo?.day ? [searchInfo.day] : [], - times: searchInfo?.time ? [searchInfo.time] : [], - })) - setPage(1); - }, [searchInfo]); - - return ( - - - - 수업 검색 - - - - - - 검색어 - changeSearchOption('query', e.target.value)} - /> - - - - 학점 - - - - - - - 학년 - changeSearchOption('grades', value.map(Number))} - > - - {[1, 2, 3, 4].map(grade => ( - {grade}학년 - ))} - - - - - - 요일 - changeSearchOption('days', value as string[])} - > - - {DAY_LABELS.map(day => ( - {day} - ))} - - - - - - - - 시간 - changeSearchOption('times', values.map(Number))} - > - - {searchOptions.times.sort((a, b) => a - b).map(time => ( - - {time}교시 - changeSearchOption('times', searchOptions.times.filter(v => v !== time))}/> - - ))} - - - {TIME_SLOTS.map(({ id, label }) => ( - - - {id}교시({label}) - - - ))} - - - - - - 전공 - changeSearchOption('majors', values as string[])} - > - - {searchOptions.majors.map(major => ( - - {major.split("

").pop()} - changeSearchOption('majors', searchOptions.majors.filter(v => v !== major))}/> - - ))} - - - {allMajors.map(major => ( - - - {major.replace(/

/gi, ' ')} - - - ))} - - - - - - 검색결과: {filteredLectures.length}개 - - - - - - - - - - - - - - -
과목코드학년과목명학점전공시간
- - - - - {visibleLectures.map((lecture, index) => ( - - - - - - - - ))} - -
{lecture.id}{lecture.grade}{lecture.title}{lecture.credits} - - - -
- - -
- - - - - ); -}; - -export default SearchDialog; \ No newline at end of file diff --git a/src/components/schedule/DragOverlayContent.tsx b/src/components/schedule/DragOverlayContent.tsx new file mode 100644 index 0000000..56290b2 --- /dev/null +++ b/src/components/schedule/DragOverlayContent.tsx @@ -0,0 +1,33 @@ +import { Box, Text } from "@chakra-ui/react"; +import { useDndContext } from "@dnd-kit/core"; + +import { CellSize } from "@/constants"; +import { ScheduleDragData } from "@/types"; + +function DragOverlayContent() { + const { active } = useDndContext(); + + if (!active?.data.current) return null; + + const { schedule, bg } = active.data.current as ScheduleDragData; + const size = schedule.range.length; + + return ( + + + {schedule.lecture.title} + + {schedule.room} + + ); +} + +export default DragOverlayContent; diff --git a/src/components/schedule/DraggableSchedule.tsx b/src/components/schedule/DraggableSchedule.tsx new file mode 100644 index 0000000..3bab6a7 --- /dev/null +++ b/src/components/schedule/DraggableSchedule.tsx @@ -0,0 +1,61 @@ +import { memo, useCallback } from "react"; +import { useDraggable } from "@dnd-kit/core"; + +import { Schedule } from "@/types"; +import { calculateSchedulePosition } from "@/lib/schedule-grid"; +import ScheduleItem from "./ScheduleItem"; + +interface Props { + id: string; + data: Schedule; + bg: string; + tableId: string; + onDelete?: (tableId: string, timeInfo: { day: string; time: number }) => void; +} + +const DraggableSchedule = memo( + ({ id, data, bg, tableId, onDelete }: Props) => { + const { day, range, room, lecture } = data; + const { attributes, setNodeRef, listeners } = useDraggable({ + id, + data: { schedule: data, bg }, + }); + + const { left, top, width, height } = calculateSchedulePosition(day, range); + + const handleDelete = useCallback(() => { + onDelete?.(tableId, { day, time: range[0] }); + }, [onDelete, tableId, day, range]); + + return ( + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.id === nextProps.id && + prevProps.bg === nextProps.bg && + prevProps.data.day === nextProps.data.day && + prevProps.data.range[0] === nextProps.data.range[0] && + prevProps.data.range.length === nextProps.data.range.length && + prevProps.data.lecture.id === nextProps.data.lecture.id + ); + } +); + +DraggableSchedule.displayName = "DraggableSchedule"; + +export default DraggableSchedule; diff --git a/src/components/schedule/ScheduleGrid.tsx b/src/components/schedule/ScheduleGrid.tsx new file mode 100644 index 0000000..588d25f --- /dev/null +++ b/src/components/schedule/ScheduleGrid.tsx @@ -0,0 +1,67 @@ +import { memo, Fragment } from "react"; +import { Flex, Grid, GridItem, Text } from "@chakra-ui/react"; + +import { CellSize, DAY_LABELS, TIME_SLOTS } from "@/constants"; +import { fill2 } from "@/lib/utils"; + +interface Props { + tableId: string; + onScheduleTimeClick?: (tableId: string, timeInfo: { day: string; time: number }) => void; +} + +const ScheduleGrid = memo(({ tableId, onScheduleTimeClick }: Props) => { + return ( + + + + 교시 + + + {DAY_LABELS.map((day) => ( + + + {day} + + + ))} + {TIME_SLOTS.map((slot, timeIndex) => ( + + 17 ? "gray.200" : "gray.100"} + > + + + {fill2(slot.id)} ({slot.label}) + + + + {DAY_LABELS.map((day) => ( + 17 ? "gray.100" : "white"} + cursor="pointer" + _hover={{ bg: "yellow.100" }} + onClick={() => onScheduleTimeClick?.(tableId, { day, time: slot.id })} + /> + ))} + + ))} + + ); +}); + +ScheduleGrid.displayName = "ScheduleGrid"; + +export default ScheduleGrid; diff --git a/src/components/schedule/ScheduleItem.tsx b/src/components/schedule/ScheduleItem.tsx new file mode 100644 index 0000000..fe7a018 --- /dev/null +++ b/src/components/schedule/ScheduleItem.tsx @@ -0,0 +1,100 @@ +import { memo } from "react"; +import { + Box, + Button, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverTrigger, + Text, +} from "@chakra-ui/react"; +import { useDraggable } from "@dnd-kit/core"; + +interface Props { + scheduleId: string; + left: number; + top: number; + width: number; + height: number; + bg: string; + title: string; + room?: string; + onDelete: () => void; + setNodeRef: (element: HTMLElement | null) => void; + listeners?: ReturnType["listeners"]; + attributes?: ReturnType["attributes"]; +} + +const ScheduleItem = memo( + ({ + scheduleId, + left, + top, + width, + height, + bg, + title, + room, + onDelete, + setNodeRef, + listeners, + attributes, + }: Props) => { + return ( + + + + + + {title} + + {room} + + + event.stopPropagation()}> + + + + 강의를 삭제하시겠습니까? + + + + + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.scheduleId === nextProps.scheduleId && + prevProps.left === nextProps.left && + prevProps.top === nextProps.top && + prevProps.width === nextProps.width && + prevProps.height === nextProps.height && + prevProps.bg === nextProps.bg && + prevProps.title === nextProps.title && + prevProps.room === nextProps.room + ); + } +); + +ScheduleItem.displayName = "ScheduleItem"; + +export default ScheduleItem; diff --git a/src/components/schedule/ScheduleTable.tsx b/src/components/schedule/ScheduleTable.tsx new file mode 100644 index 0000000..3dbc86b --- /dev/null +++ b/src/components/schedule/ScheduleTable.tsx @@ -0,0 +1,141 @@ +import { memo, useCallback, useMemo, useRef } from "react"; +import { Box } from "@chakra-ui/react"; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; + +import { CellSize, DAY_LABELS } from "@/constants"; +import { Schedule } from "@/types"; +import { createColorMap } from "@/lib/schedule-grid"; +import { snapModifiers } from "@/lib/dnd-modifiers"; +import { useDragStyle } from "@/hooks/useDragStyle"; + +import ScheduleGrid from "./ScheduleGrid"; +import DraggableSchedule from "./DraggableSchedule"; +import DragOverlayContent from "./DragOverlayContent"; + +interface Props { + tableId: string; + schedules: Schedule[]; + onScheduleTimeClick?: (tableId: string, timeInfo: { day: string; time: number }) => void; + onDeleteButtonClick?: (tableId: string, timeInfo: { day: string; time: number }) => void; + onScheduleUpdate?: (tableId: string, index: number, newDay: string, newRange: number[]) => void; +} + +// schedules 배열 내용 비교 함수 +const areSchedulesEqual = (prev: Schedule[], next: Schedule[]): boolean => { + if (prev === next) return true; + if (prev.length !== next.length) return false; + + for (let i = 0; i < prev.length; i++) { + const p = prev[i]; + const n = next[i]; + if ( + p.day !== n.day || + p.range[0] !== n.range[0] || + p.range.length !== n.range.length || + p.lecture.id !== n.lecture.id + ) { + return false; + } + } + return true; +}; + +const ScheduleTable = memo( + ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick, onScheduleUpdate }: Props) => { + const activeIdRef = useRef(null); + const { injectDragStyle, removeDragStyle } = useDragStyle(); + + // 콜백을 ref로 저장하여 memo 비교에서 제외 + const onScheduleUpdateRef = useRef(onScheduleUpdate); + onScheduleUpdateRef.current = onScheduleUpdate; + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const colorMap = useMemo(() => createColorMap(schedules), [schedules]); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const id = String(event.active.id); + activeIdRef.current = id; + injectDragStyle(id); + }, + [injectDragStyle] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + removeDragStyle(); + activeIdRef.current = null; + + const { delta } = event; + const [, indexStr] = String(event.active.id).split(":"); + const index = Number(indexStr); + const moveDayIndex = Math.floor(delta.x / CellSize.WIDTH); + const moveTimeIndex = Math.floor(delta.y / CellSize.HEIGHT); + + if (moveDayIndex === 0 && moveTimeIndex === 0) return; + + const schedule = schedules[index]; + const nowDayIndex = DAY_LABELS.indexOf(schedule.day as (typeof DAY_LABELS)[number]); + const newDay = DAY_LABELS[nowDayIndex + moveDayIndex]; + const newRange = schedule.range.map((time) => time + moveTimeIndex); + + onScheduleUpdateRef.current?.(tableId, index, newDay, newRange); + }, + [schedules, tableId, removeDragStyle] + ); + + const handleDragCancel = useCallback(() => { + removeDragStyle(); + activeIdRef.current = null; + }, [removeDragStyle]); + + return ( + + + + + {schedules.map((schedule, index) => ( + + ))} + + + + + + + ); + }, + (prevProps, nextProps) => + prevProps.tableId === nextProps.tableId && + areSchedulesEqual(prevProps.schedules, nextProps.schedules) +); + +ScheduleTable.displayName = "ScheduleTable"; + +export default ScheduleTable; diff --git a/src/components/schedule/ScheduleTables.tsx b/src/components/schedule/ScheduleTables.tsx new file mode 100644 index 0000000..c798c3e --- /dev/null +++ b/src/components/schedule/ScheduleTables.tsx @@ -0,0 +1,125 @@ +import { Button, ButtonGroup, Flex, Heading, Stack } from '@chakra-ui/react'; +import { lazy, Suspense, useCallback, useState } from 'react'; + +import { useScheduleContext, useScheduleCommand } from '@/contexts/schedule'; +import ScheduleTable from './ScheduleTable'; + +const SearchDialog = lazy(() => import('@/components/search/SearchDialog')); + +export const ScheduleTables = () => { + const { schedulesMap } = useScheduleContext(); + const setSchedulesMap = useScheduleCommand(); + const [searchInfo, setSearchInfo] = useState<{ + tableId: string; + day?: string; + time?: number; + } | null>(null); + + const disabledRemoveButton = Object.keys(schedulesMap).length === 1; + + const handleDuplicate = useCallback((targetId: string) => { + setSchedulesMap((prev) => ({ + ...prev, + [`schedule-${Date.now()}`]: [...prev[targetId]], + })); + }, [setSchedulesMap]); + + const handleRemove = useCallback((targetId: string) => { + setSchedulesMap((prev) => { + const { [targetId]: _removed, ...rest } = prev; + void _removed; + return rest; + }); + }, [setSchedulesMap]); + + const handleSearchOpen = useCallback((tableId: string) => { + setSearchInfo({ tableId }); + }, []); + + const handleScheduleTimeClick = useCallback( + (tableId: string, timeInfo: { day: string; time: number }) => { + setSearchInfo({ tableId, ...timeInfo }); + }, + [] + ); + + const handleDeleteButtonClick = useCallback( + (tableId: string, { day, time }: { day: string; time: number }) => { + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: prev[tableId].filter( + (schedule) => schedule.day !== day || !schedule.range.includes(time) + ), + })); + }, + [setSchedulesMap] + ); + + const handleScheduleUpdate = useCallback( + (tableId: string, index: number, newDay: string, newRange: number[]) => { + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: prev[tableId].map((schedule, i) => + i === index + ? { ...schedule, day: newDay, range: newRange } + : schedule + ), + })); + }, + [setSchedulesMap] + ); + + const handleClose = useCallback(() => { + setSearchInfo(null); + }, []); + + return ( + <> + + {Object.entries(schedulesMap).map(([tableId, schedules], index) => ( + + + + 시간표 {index + 1} + + + + + + + + + + ))} + + {searchInfo && ( + + + + )} + + ); +}; diff --git a/src/components/schedule/index.ts b/src/components/schedule/index.ts new file mode 100644 index 0000000..85422d6 --- /dev/null +++ b/src/components/schedule/index.ts @@ -0,0 +1,6 @@ +export { default as ScheduleTable } from "./ScheduleTable"; +export { default as ScheduleGrid } from "./ScheduleGrid"; +export { default as ScheduleItem } from "./ScheduleItem"; +export { default as DraggableSchedule } from "./DraggableSchedule"; +export { default as DragOverlayContent } from "./DragOverlayContent"; +export { ScheduleTables } from "./ScheduleTables"; diff --git a/src/components/search/LectureRow.tsx b/src/components/search/LectureRow.tsx new file mode 100644 index 0000000..48bcba1 --- /dev/null +++ b/src/components/search/LectureRow.tsx @@ -0,0 +1,36 @@ +import { memo } from 'react'; +import { Button, Td, Tr } from '@chakra-ui/react'; +import { Lecture } from '@/types'; + +interface Props { + lecture: Lecture; + onAdd: (lecture: Lecture) => void; +} + +export const LectureRow = memo(({ lecture, onAdd }: Props) => ( + + {lecture.id} + {lecture.grade} + {lecture.title} + {lecture.credits} + + + + + + +)); + +LectureRow.displayName = 'LectureRow'; diff --git a/src/components/search/MajorCheckboxList.tsx b/src/components/search/MajorCheckboxList.tsx new file mode 100644 index 0000000..5e6834e --- /dev/null +++ b/src/components/search/MajorCheckboxList.tsx @@ -0,0 +1,20 @@ +import { memo } from 'react'; +import { Box, Checkbox } from '@chakra-ui/react'; + +interface Props { + majors: string[]; +} + +export const MajorCheckboxList = memo(({ majors }: Props) => ( + <> + {majors.map((major) => ( + + + {major.replace(/

/gi, ' ')} + + + ))} + +)); + +MajorCheckboxList.displayName = 'MajorCheckboxList'; diff --git a/src/components/search/SearchDialog.tsx b/src/components/search/SearchDialog.tsx new file mode 100644 index 0000000..9a9e685 --- /dev/null +++ b/src/components/search/SearchDialog.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Box, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Table, + Tbody, + Text, + Th, + Thead, + Tr, + VStack, +} from '@chakra-ui/react'; +import axios from 'axios'; + +import { useScheduleCommand } from '@/contexts/schedule'; +import { Lecture } from '@/types'; +import { parseSchedule } from '@/lib/utils'; +import { createQueryCache } from '@/lib/queryCache'; +import { filterLectures, SearchOption } from './SearchDialog.utils'; +import { LectureRow } from './LectureRow'; +import { SearchFilters } from './SearchFilters'; + +interface Props { + searchInfo: { + tableId: string; + day?: string; + time?: number; + }; + onClose: () => void; +} + +const PAGE_SIZE = 100; + +// 캐시 인스턴스 생성 (모듈 레벨) +const queryCache = createQueryCache(); + +// API 호출 함수 +const BASE_URL = import.meta.env.BASE_URL; +const fetchMajors = () => axios.get(`${BASE_URL}schedules-majors.json`); +const fetchLiberalArts = () => + axios.get(`${BASE_URL}schedules-liberal-arts.json`); + +// 캐시를 활용한 API 호출 (병렬 실행 + 중복 호출 방지) +const fetchAllLectures = () => + Promise.all([ + queryCache.fetch('majors', fetchMajors), + queryCache.fetch('liberalArts', fetchLiberalArts), + queryCache.fetch('majors', fetchMajors), + queryCache.fetch('liberalArts', fetchLiberalArts), + queryCache.fetch('majors', fetchMajors), + queryCache.fetch('liberalArts', fetchLiberalArts), + ]); + +const SearchDialog = ({ searchInfo, onClose }: Props) => { + const setSchedulesMap = useScheduleCommand(); + + const loaderWrapperRef = useRef(null); + const loaderRef = useRef(null); + const [lectures, setLectures] = useState([]); + const [page, setPage] = useState(1); + const [searchOptions, setSearchOptions] = useState({ + query: '', + grades: [], + days: [], + times: [], + majors: [], + }); + + // useMemo로 필터링 결과 메모이제이션 (순수 함수 활용) + const filteredLectures = useMemo( + () => filterLectures(lectures, searchOptions), + [lectures, searchOptions] + ); + + const lastPage = Math.ceil(filteredLectures.length / PAGE_SIZE); + const visibleLectures = useMemo( + () => filteredLectures.slice(0, page * PAGE_SIZE), + [filteredLectures, page] + ); + const allMajors = useMemo( + () => [...new Set(lectures.map((lecture) => lecture.major))], + [lectures] + ); + + const changeSearchOption = useCallback( + (field: keyof SearchOption, value: SearchOption[typeof field]) => { + setPage(1); + setSearchOptions((prev) => ({ ...prev, [field]: value })); + loaderWrapperRef.current?.scrollTo(0, 0); + }, + [] + ); + + const addSchedule = useCallback( + (lecture: Lecture) => { + if (!searchInfo) return; + + const { tableId } = searchInfo; + + const schedules = parseSchedule(lecture.schedule).map((schedule) => ({ + ...schedule, + lecture, + })); + + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: [...prev[tableId], ...schedules], + })); + + onClose(); + }, + [searchInfo, setSchedulesMap, onClose] + ); + + useEffect(() => { + fetchAllLectures().then((results) => { + setLectures(results.flatMap((result) => result.data)); + }); + }, []); + + useEffect(() => { + const $loader = loaderRef.current; + const $loaderWrapper = loaderWrapperRef.current; + + if (!$loader || !$loaderWrapper) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + setPage((prevPage) => Math.min(lastPage, prevPage + 1)); + } + }, + { threshold: 0, root: $loaderWrapper } + ); + + observer.observe($loader); + + return () => observer.unobserve($loader); + }, [lastPage]); + + useEffect(() => { + setSearchOptions((prev) => ({ + ...prev, + days: searchInfo?.day ? [searchInfo.day] : [], + times: searchInfo?.time ? [searchInfo.time] : [], + })); + setPage(1); + }, [searchInfo]); + + return ( + + + + 수업 검색 + + + + + 검색결과: {filteredLectures.length}개 + + + + + + + + + + + + + +
과목코드학년과목명학점전공시간
+ + + + + {visibleLectures.map((lecture, index) => ( + + ))} + +
+ + +
+
+
+
+
+ ); +}; + +export default SearchDialog; diff --git a/src/components/search/SearchDialog.utils.ts b/src/components/search/SearchDialog.utils.ts new file mode 100644 index 0000000..23bbe23 --- /dev/null +++ b/src/components/search/SearchDialog.utils.ts @@ -0,0 +1,44 @@ +import { Lecture } from '@/types'; +import { parseSchedule } from '@/lib/utils'; + +export interface SearchOption { + query?: string; + grades: number[]; + days: string[]; + times: number[]; + majors: string[]; + credits?: number; +} + +export const filterLectures = ( + lectures: Lecture[], + options: SearchOption +): Lecture[] => { + const { query = '', credits, grades, days, times, majors } = options; + + return lectures + .filter( + (lecture) => + lecture.title.toLowerCase().includes(query.toLowerCase()) || + lecture.id.toLowerCase().includes(query.toLowerCase()) + ) + .filter((lecture) => grades.length === 0 || grades.includes(lecture.grade)) + .filter((lecture) => majors.length === 0 || majors.includes(lecture.major)) + .filter( + (lecture) => !credits || lecture.credits.startsWith(String(credits)) + ) + .filter((lecture) => { + if (days.length === 0) return true; + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + return schedules.some((s) => days.includes(s.day)); + }) + .filter((lecture) => { + if (times.length === 0) return true; + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + return schedules.some((s) => s.range.some((time) => times.includes(time))); + }); +}; diff --git a/src/components/search/SearchFilters.tsx b/src/components/search/SearchFilters.tsx new file mode 100644 index 0000000..46a680d --- /dev/null +++ b/src/components/search/SearchFilters.tsx @@ -0,0 +1,152 @@ +import { memo, useCallback } from 'react'; +import { HStack } from '@chakra-ui/react'; +import { SearchOption } from './SearchDialog.utils'; +import { + QueryInput, + CreditsSelect, + GradeFilter, + DayFilter, + TimeFilter, + MajorFilter, +} from './filters'; + +interface Props { + searchOptions: SearchOption; + allMajors: string[]; + changeSearchOption: ( + field: keyof SearchOption, + value: SearchOption[typeof field] + ) => void; +} + +const TopFilters = memo( + ({ + query = '', + credits, + onQueryChange, + onCreditsChange, + }: { + query?: string; + credits?: number; + onQueryChange: (value: string) => void; + onCreditsChange: (value: number | undefined) => void; + }) => { + return ( + + + + + ); + } +); +TopFilters.displayName = 'TopFilters'; + +const MiddleFilters = memo( + ({ + grades, + days, + onGradesChange, + onDaysChange, + }: { + grades: number[]; + days: string[]; + onGradesChange: (value: number[]) => void; + onDaysChange: (value: string[]) => void; + }) => { + return ( + + + + + ); + } +); +MiddleFilters.displayName = 'MiddleFilters'; + +const BottomFilters = memo( + ({ + times, + majors, + allMajors, + onTimesChange, + onMajorsChange, + }: { + times: number[]; + majors: string[]; + allMajors: string[]; + onTimesChange: (value: number[]) => void; + onMajorsChange: (value: string[]) => void; + }) => { + return ( + + + + + ); + } +); +BottomFilters.displayName = 'BottomFilters'; + +export const SearchFilters = memo( + ({ searchOptions, allMajors, changeSearchOption }: Props) => { + const handleQueryChange = useCallback( + (value: string) => changeSearchOption('query', value), + [changeSearchOption] + ); + + const handleCreditsChange = useCallback( + (value: number | undefined) => changeSearchOption('credits', value), + [changeSearchOption] + ); + + const handleGradesChange = useCallback( + (value: number[]) => changeSearchOption('grades', value), + [changeSearchOption] + ); + + const handleDaysChange = useCallback( + (value: string[]) => changeSearchOption('days', value), + [changeSearchOption] + ); + + const handleTimesChange = useCallback( + (value: number[]) => changeSearchOption('times', value), + [changeSearchOption] + ); + + const handleMajorsChange = useCallback( + (value: string[]) => changeSearchOption('majors', value), + [changeSearchOption] + ); + + return ( + <> + + + + + ); + } +); + +SearchFilters.displayName = 'SearchFilters'; diff --git a/src/components/search/filters/CreditsSelect.tsx b/src/components/search/filters/CreditsSelect.tsx new file mode 100644 index 0000000..0e879c4 --- /dev/null +++ b/src/components/search/filters/CreditsSelect.tsx @@ -0,0 +1,28 @@ +import { memo } from 'react'; +import { FormControl, FormLabel, Select } from '@chakra-ui/react'; + +interface CreditsSelectProps { + value?: number; + onChange: (value: number | undefined) => void; +} + +export const CreditsSelect = memo(({ value, onChange }: CreditsSelectProps) => { + const handleChange = (e: React.ChangeEvent) => { + const v = e.target.value; + onChange(v ? Number(v) : undefined); + }; + + return ( + + 학점 + + + ); +}); + +CreditsSelect.displayName = 'CreditsSelect'; diff --git a/src/components/search/filters/DayFilter.tsx b/src/components/search/filters/DayFilter.tsx new file mode 100644 index 0000000..a42c277 --- /dev/null +++ b/src/components/search/filters/DayFilter.tsx @@ -0,0 +1,36 @@ +import { memo } from 'react'; +import { + Checkbox, + CheckboxGroup, + FormControl, + FormLabel, + HStack, +} from '@chakra-ui/react'; +import { DAY_LABELS } from '@/constants'; + +interface DayFilterProps { + value: string[]; + onChange: (value: string[]) => void; +} + +export const DayFilter = memo(({ value, onChange }: DayFilterProps) => { + return ( + + 요일 + onChange(v as string[])} + > + + {DAY_LABELS.map((day) => ( + + {day} + + ))} + + + + ); +}); + +DayFilter.displayName = 'DayFilter'; diff --git a/src/components/search/filters/GradeFilter.tsx b/src/components/search/filters/GradeFilter.tsx new file mode 100644 index 0000000..63f3084 --- /dev/null +++ b/src/components/search/filters/GradeFilter.tsx @@ -0,0 +1,35 @@ +import { memo } from 'react'; +import { + Checkbox, + CheckboxGroup, + FormControl, + FormLabel, + HStack, +} from '@chakra-ui/react'; + +interface GradeFilterProps { + value: number[]; + onChange: (value: number[]) => void; +} + +export const GradeFilter = memo(({ value, onChange }: GradeFilterProps) => { + return ( + + 학년 + onChange(v.map(Number))} + > + + {[1, 2, 3, 4].map((grade) => ( + + {grade}학년 + + ))} + + + + ); +}); + +GradeFilter.displayName = 'GradeFilter'; diff --git a/src/components/search/filters/MajorFilter.tsx b/src/components/search/filters/MajorFilter.tsx new file mode 100644 index 0000000..40f2ee2 --- /dev/null +++ b/src/components/search/filters/MajorFilter.tsx @@ -0,0 +1,57 @@ +import { memo } from 'react'; +import { + CheckboxGroup, + FormControl, + FormLabel, + Stack, + Tag, + TagCloseButton, + TagLabel, + Wrap, +} from '@chakra-ui/react'; +import { MajorCheckboxList } from '../MajorCheckboxList'; + +interface MajorFilterProps { + value: string[]; + allMajors: string[]; + onChange: (value: string[]) => void; +} + +export const MajorFilter = memo(({ value, allMajors, onChange }: MajorFilterProps) => { + const handleRemove = (major: string) => { + onChange(value.filter((v) => v !== major)); + }; + + return ( + + 전공 + onChange(v as string[])} + > + + {value.map((major) => ( + + {major.split('

').pop()} + handleRemove(major)} /> + + ))} + + + + + + + ); +}); + +MajorFilter.displayName = 'MajorFilter'; diff --git a/src/components/search/filters/QueryInput.tsx b/src/components/search/filters/QueryInput.tsx new file mode 100644 index 0000000..3be55f9 --- /dev/null +++ b/src/components/search/filters/QueryInput.tsx @@ -0,0 +1,32 @@ +import { memo, useEffect, useState } from 'react'; +import { FormControl, FormLabel, Input } from '@chakra-ui/react'; +import { useDebounce } from '@/hooks/useDebounce'; + +interface QueryInputProps { + initialValue: string; + onChange: (value: string) => void; +} + +export const QueryInput = memo(({ initialValue, onChange }: QueryInputProps) => { + const [value, setValue] = useState(initialValue); + const debouncedValue = useDebounce(value, 300); + + useEffect(() => { + if (debouncedValue !== initialValue) { + onChange(debouncedValue); + } + }, [debouncedValue, onChange, initialValue]); + + return ( + + 검색어 + setValue(e.target.value)} + /> + + ); +}); + +QueryInput.displayName = 'QueryInput'; diff --git a/src/components/search/filters/TimeFilter.tsx b/src/components/search/filters/TimeFilter.tsx new file mode 100644 index 0000000..633294a --- /dev/null +++ b/src/components/search/filters/TimeFilter.tsx @@ -0,0 +1,66 @@ +import { memo } from 'react'; +import { + Box, + Checkbox, + CheckboxGroup, + FormControl, + FormLabel, + Stack, + Tag, + TagCloseButton, + TagLabel, + Wrap, +} from '@chakra-ui/react'; +import { TIME_SLOTS } from '@/constants'; + +interface TimeFilterProps { + value: number[]; + onChange: (value: number[]) => void; +} + +export const TimeFilter = memo(({ value, onChange }: TimeFilterProps) => { + const handleRemove = (time: number) => { + onChange(value.filter((v) => v !== time)); + }; + + return ( + + 시간 + onChange(v.map(Number))} + > + + {value + .sort((a, b) => a - b) + .map((time) => ( + + {time}교시 + handleRemove(time)} /> + + ))} + + + {TIME_SLOTS.map(({ id, label }) => ( + + + {id}교시({label}) + + + ))} + + + + ); +}); + +TimeFilter.displayName = 'TimeFilter'; diff --git a/src/components/search/filters/index.ts b/src/components/search/filters/index.ts new file mode 100644 index 0000000..22ddb5c --- /dev/null +++ b/src/components/search/filters/index.ts @@ -0,0 +1,6 @@ +export { QueryInput } from './QueryInput'; +export { CreditsSelect } from './CreditsSelect'; +export { GradeFilter } from './GradeFilter'; +export { DayFilter } from './DayFilter'; +export { TimeFilter } from './TimeFilter'; +export { MajorFilter } from './MajorFilter'; diff --git a/src/constants.ts b/src/constants.ts index c7c2e46..74c97db 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,5 +5,42 @@ export const CellSize = { HEIGHT: 30, }; +// 그리드 레이아웃 상수 +export const GRID_HEADER_HEIGHT = 40; +export const TIME_COLUMN_WIDTH = 120; +export const GRID_BORDER_WIDTH = 1; + +// 스케줄 색상 +export const SCHEDULE_COLORS = [ + "#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd" +] as const; + export const 초 = 1000; export const 분 = 60 * 초; + +export const TIME_SLOTS = [ + { id: 1, label: '09:00~09:30' }, + { id: 2, label: '09:30~10:00' }, + { id: 3, label: '10:00~10:30' }, + { id: 4, label: '10:30~11:00' }, + { id: 5, label: '11:00~11:30' }, + { id: 6, label: '11:30~12:00' }, + { id: 7, label: '12:00~12:30' }, + { id: 8, label: '12:30~13:00' }, + { id: 9, label: '13:00~13:30' }, + { id: 10, label: '13:30~14:00' }, + { id: 11, label: '14:00~14:30' }, + { id: 12, label: '14:30~15:00' }, + { id: 13, label: '15:00~15:30' }, + { id: 14, label: '15:30~16:00' }, + { id: 15, label: '16:00~16:30' }, + { id: 16, label: '16:30~17:00' }, + { id: 17, label: '17:00~17:30' }, + { id: 18, label: '17:30~18:00' }, + { id: 19, label: '18:00~18:50' }, + { id: 20, label: '18:55~19:45' }, + { id: 21, label: '19:50~20:40' }, + { id: 22, label: '20:45~21:35' }, + { id: 23, label: '21:40~22:30' }, + { id: 24, label: '22:35~23:25' }, +] as const; diff --git a/src/contexts/schedule/ScheduleProvider.tsx b/src/contexts/schedule/ScheduleProvider.tsx new file mode 100644 index 0000000..43b0ab6 --- /dev/null +++ b/src/contexts/schedule/ScheduleProvider.tsx @@ -0,0 +1,20 @@ +import { PropsWithChildren, useState } from 'react'; +import dummyScheduleMap from '@/data/dummyScheduleMap'; +import { + ScheduleQueryContext, + ScheduleCommandContext, + SchedulesMap, +} from './scheduleContext'; + +export const ScheduleProvider = ({ children }: PropsWithChildren) => { + const [schedulesMap, setSchedulesMap] = + useState(dummyScheduleMap); + + return ( + + + {children} + + + ); +}; diff --git a/src/contexts/schedule/index.ts b/src/contexts/schedule/index.ts new file mode 100644 index 0000000..b660e1d --- /dev/null +++ b/src/contexts/schedule/index.ts @@ -0,0 +1,6 @@ +export { ScheduleProvider } from './ScheduleProvider'; +export { + useScheduleContext, + useScheduleQuery, + useScheduleCommand, +} from './scheduleContext'; diff --git a/src/contexts/schedule/scheduleContext.ts b/src/contexts/schedule/scheduleContext.ts new file mode 100644 index 0000000..b0c497c --- /dev/null +++ b/src/contexts/schedule/scheduleContext.ts @@ -0,0 +1,52 @@ +import React, { createContext, useContext } from 'react'; +import { Schedule } from '@/types'; + +export type SchedulesMap = Record; + +// Query Context - 값 조회용 (값 변경 시 리렌더링 발생) +export const ScheduleQueryContext = createContext( + undefined +); + +// Command Context - 값 변경용 (setter는 동일 참조 유지) +export const ScheduleCommandContext = createContext< + React.Dispatch> | undefined +>(undefined); + +// 통합 훅 - 값 조회와 변경 모두 필요할 때 +export const useScheduleContext = () => { + const schedulesMap = useContext(ScheduleQueryContext); + const setSchedulesMap = useContext(ScheduleCommandContext); + + if (schedulesMap === undefined || setSchedulesMap === undefined) { + throw new Error( + 'useScheduleContext must be used within a ScheduleProvider' + ); + } + + return { schedulesMap, setSchedulesMap }; +}; + +// Query 전용 훅 - 값 조회만 필요할 때 +export const useScheduleQuery = () => { + const schedulesMap = useContext(ScheduleQueryContext); + + if (schedulesMap === undefined) { + throw new Error('useScheduleQuery must be used within a ScheduleProvider'); + } + + return schedulesMap; +}; + +// Command 전용 훅 - 값 변경만 필요할 때 (리렌더링 최소화) +export const useScheduleCommand = () => { + const setSchedulesMap = useContext(ScheduleCommandContext); + + if (setSchedulesMap === undefined) { + throw new Error( + 'useScheduleCommand must be used within a ScheduleProvider' + ); + } + + return setSchedulesMap; +}; diff --git a/src/dummyScheduleMap.ts b/src/data/dummyScheduleMap.ts similarity index 100% rename from src/dummyScheduleMap.ts rename to src/data/dummyScheduleMap.ts diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..b0be07b --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/src/hooks/useDragStyle.ts b/src/hooks/useDragStyle.ts new file mode 100644 index 0000000..db05bfd --- /dev/null +++ b/src/hooks/useDragStyle.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef } from "react"; + +/** + * 드래그 중인 아이템의 스타일을 CSS 주입으로 제어하는 훅 + * React 상태 변경 없이 시각적 피드백 제공 + */ +export function useDragStyle() { + const styleRef = useRef(null); + + const injectDragStyle = useCallback((id: string) => { + if (styleRef.current) { + document.head.removeChild(styleRef.current); + } + const style = document.createElement("style"); + style.textContent = `[data-schedule-id="${id}"] { visibility: hidden !important; }`; + document.head.appendChild(style); + styleRef.current = style; + }, []); + + const removeDragStyle = useCallback(() => { + if (styleRef.current) { + document.head.removeChild(styleRef.current); + styleRef.current = null; + } + }, []); + + // 컴포넌트 언마운트 시 스타일 정리 + useEffect(() => { + return () => { + if (styleRef.current) { + document.head.removeChild(styleRef.current); + } + }; + }, []); + + return { injectDragStyle, removeDragStyle }; +} diff --git a/src/lib/dnd-modifiers.ts b/src/lib/dnd-modifiers.ts new file mode 100644 index 0000000..46dc554 --- /dev/null +++ b/src/lib/dnd-modifiers.ts @@ -0,0 +1,69 @@ +import { Modifier } from "@dnd-kit/core"; +import { + CellSize, + DAY_LABELS, + TIME_SLOTS, + TIME_COLUMN_WIDTH, + GRID_HEADER_HEIGHT, +} from "@/constants"; + +/** + * 그리드 영역 내로 드래그를 제한하는 모디파이어 + * 교시 열과 요일 헤더를 제외한 실제 스케줄 영역 내에서만 이동 가능 + */ +export function createGridBoundaryModifier(): Modifier { + return ({ transform, draggingNodeRect, containerNodeRect }) => { + if (!draggingNodeRect || !containerNodeRect) { + return transform; + } + + // 그리드 영역 경계 계산 + const gridLeft = TIME_COLUMN_WIDTH; + const gridTop = GRID_HEADER_HEIGHT; + const gridRight = TIME_COLUMN_WIDTH + DAY_LABELS.length * CellSize.WIDTH; + const gridBottom = GRID_HEADER_HEIGHT + TIME_SLOTS.length * CellSize.HEIGHT; + + // 현재 드래그 중인 요소의 위치 + const currentLeft = draggingNodeRect.left - containerNodeRect.left; + const currentTop = draggingNodeRect.top - containerNodeRect.top; + const currentRight = currentLeft + draggingNodeRect.width; + const currentBottom = currentTop + draggingNodeRect.height; + + let { x, y } = transform; + + // 왼쪽 경계 (교시 열 침범 방지) + if (currentLeft + x < gridLeft) { + x = gridLeft - currentLeft; + } + // 오른쪽 경계 + if (currentRight + x > gridRight) { + x = gridRight - currentRight; + } + // 상단 경계 (헤더 침범 방지) + if (currentTop + y < gridTop) { + y = gridTop - currentTop; + } + // 하단 경계 + if (currentBottom + y > gridBottom) { + y = gridBottom - currentBottom; + } + + return { ...transform, x, y }; + }; +} + +/** + * 그리드 셀에 스냅되는 드래그 모디파이어 + * transform을 셀 크기 단위로 스냅 + */ +export function createSnapModifier(): Modifier { + return ({ transform }) => { + return { + ...transform, + x: Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, + y: Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, + }; + }; +} + +export const snapModifiers = [createGridBoundaryModifier(), createSnapModifier()]; diff --git a/src/lib/queryCache.ts b/src/lib/queryCache.ts new file mode 100644 index 0000000..2563d62 --- /dev/null +++ b/src/lib/queryCache.ts @@ -0,0 +1,58 @@ +type CacheEntry = { + promise: Promise; + status: 'pending' | 'success' | 'error'; +}; + +export const createQueryCache = () => { + const cache = new Map>(); + + return { + /** + * 데이터를 가져옵니다. 캐시가 있으면 캐시를 반환합니다. + */ + fetch: (key: string, fetchFn: () => Promise): Promise => { + const existing = cache.get(key) as CacheEntry | undefined; + + // 캐시 히트: 에러가 아니면 재사용 + if (existing && existing.status !== 'error') { + return existing.promise; + } + + // 캐시 미스: 새로 fetch + const promise = fetchFn() + .then((data) => { + const entry = cache.get(key); + if (entry) entry.status = 'success'; + return data; + }) + .catch((error) => { + const entry = cache.get(key); + if (entry) entry.status = 'error'; + throw error; + }); + + cache.set(key, { + promise, + status: 'pending', + }); + + return promise; + }, + + /** + * 특정 키 또는 전체 캐시를 무효화합니다. + */ + invalidate: (key?: string) => { + if (key) { + cache.delete(key); + } else { + cache.clear(); + } + }, + + /** + * 캐시 상태를 조회합니다 (디버깅용). + */ + getStatus: (key: string) => cache.get(key)?.status, + }; +}; diff --git a/src/lib/schedule-grid.ts b/src/lib/schedule-grid.ts new file mode 100644 index 0000000..9b934c9 --- /dev/null +++ b/src/lib/schedule-grid.ts @@ -0,0 +1,28 @@ +import { CellSize, DAY_LABELS, GRID_BORDER_WIDTH, GRID_HEADER_HEIGHT, SCHEDULE_COLORS, TIME_COLUMN_WIDTH } from "@/constants"; +import { Schedule } from "@/types"; + +/** + * 스케줄 아이템의 그리드 위치 계산 + */ +export function calculateSchedulePosition(day: string, range: number[]) { + const dayIndex = DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]); + const topIndex = range[0] - 1; + const size = range.length; + + return { + left: TIME_COLUMN_WIDTH + (CellSize.WIDTH * dayIndex) + GRID_BORDER_WIDTH, + top: GRID_HEADER_HEIGHT + (topIndex * CellSize.HEIGHT) + GRID_BORDER_WIDTH, + width: CellSize.WIDTH - GRID_BORDER_WIDTH, + height: CellSize.HEIGHT * size - GRID_BORDER_WIDTH, + }; +} + +/** + * 스케줄 목록에서 강의별 색상 맵 생성 + */ +export function createColorMap(schedules: Schedule[]): Record { + const lectureIds = [...new Set(schedules.map(({ lecture }) => lecture.id))]; + return Object.fromEntries( + lectureIds.map((id, index) => [id, SCHEDULE_COLORS[index % SCHEDULE_COLORS.length]]) + ); +} diff --git a/src/utils.ts b/src/lib/utils.ts similarity index 100% rename from src/utils.ts rename to src/lib/utils.ts diff --git a/src/types.ts b/src/types.ts index 16118bf..446a5b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,3 +13,9 @@ export interface Schedule { range: number[] room?: string; } + +// DND 관련 타입 +export interface ScheduleDragData { + schedule: Schedule; + bg: string; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292..75100d7 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -21,7 +21,13 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + /* Path alias */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, "include": ["src"] } diff --git a/vite.config.ts b/vite.config.ts index 1cdac55..ad9b8db 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,17 @@ import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; +import path from 'path'; export default mergeConfig( defineConfig({ + base: '/front_7th_chapter4-2/', plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, }), defineTestConfig({ test: {