diff --git a/404.html b/404.html
new file mode 100644
index 00000000..46353f77
--- /dev/null
+++ b/404.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+ 시간표 제작 서비스로 학습하는 성능최적화
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 9f30638a..dbcaba35 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,8 @@
"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",
+ "gh-pages": "pnpm run build && gh-pages -d dist"
},
"dependencies": {
"@chakra-ui/icons": "2.2.4",
@@ -39,6 +40,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 704a7bc1..d15b7aa0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,6 +84,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
@@ -1243,6 +1246,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 +1322,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 +1412,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 +1457,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 +1549,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 +1621,10 @@ packages:
framesync@6.1.2:
resolution: {integrity: sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==}
+ fs-extra@11.3.1:
+ resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
+ engines: {node: '>=14.14'}
+
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -1618,6 +1658,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 +1695,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 +1832,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 +1909,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 +1952,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 +2044,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 +2122,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 +2308,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 +2388,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 +2458,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 +2503,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==}
@@ -3609,6 +3700,8 @@ snapshots:
assertion-error@2.0.1: {}
+ async@3.2.6: {}
+
asynckit@0.4.0: {}
axios@1.11.0:
@@ -3689,6 +3782,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 +3852,8 @@ snapshots:
eastasianwidth@0.2.0: {}
+ email-addresses@5.0.0: {}
+
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@@ -3836,6 +3935,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 +4049,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 +4120,12 @@ snapshots:
dependencies:
tslib: 2.4.0
+ fs-extra@11.3.1:
+ 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 +4161,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.1
+ globby: 11.1.0
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -4078,6 +4214,8 @@ snapshots:
gopd@1.2.0: {}
+ graceful-fs@4.2.11: {}
+
graphemer@1.4.0: {}
graphql@16.11.0: {}
@@ -4187,6 +4325,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 +4387,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 +4427,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 +4520,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 +4580,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 +4772,8 @@ snapshots:
scheduler@0.26.0: {}
+ semver@6.3.1: {}
+
semver@7.7.2: {}
shebang-command@2.0.0:
@@ -4668,6 +4836,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 +4891,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 +4924,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 664bf6df..ca184ad1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,16 +1,12 @@
import { ChakraProvider } from "@chakra-ui/react";
import { ScheduleProvider } from "./ScheduleContext.tsx";
import { ScheduleTables } from "./ScheduleTables.tsx";
-import ScheduleDndProvider from "./ScheduleDndProvider.tsx";
function App() {
-
return (
-
-
-
+
);
diff --git a/src/ScheduleContext.tsx b/src/ScheduleContext.tsx
index 529f0dd9..1fcc877d 100644
--- a/src/ScheduleContext.tsx
+++ b/src/ScheduleContext.tsx
@@ -1,28 +1,114 @@
-import React, { createContext, PropsWithChildren, useContext, useState } from "react";
+import React, {
+ createContext,
+ PropsWithChildren,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from "react";
import { Schedule } from "./types.ts";
import dummyScheduleMap from "./dummyScheduleMap.ts";
-interface ScheduleContextType {
+interface ScheduleActionContextType {
+ setSchedulesMap: React.Dispatch<
+ React.SetStateAction>
+ >;
+ updateTableSchedules: (
+ tableId: string,
+ updater: (prev: Schedule[]) => Schedule[]
+ ) => void;
+ addTable: (newTableId: string, schedules: Schedule[]) => void;
+ removeTable: (tableId: string) => void;
+}
+export interface ScheduleStateContextType {
schedulesMap: Record;
- setSchedulesMap: React.Dispatch>>;
+ tableCount: number;
}
+const ScheduleActionContext = createContext<
+ ScheduleActionContextType | undefined
+>(undefined);
+const ScheduleStateContext = createContext<
+ ScheduleStateContextType | undefined
+>(undefined);
-const ScheduleContext = createContext(undefined);
+export const useScheduleActionContext = () => {
+ const context = useContext(ScheduleActionContext);
+ if (context === undefined) {
+ throw new Error("useSchedule must be used within a ScheduleProvider");
+ }
+ return context;
+};
-export const useScheduleContext = () => {
- const context = useContext(ScheduleContext);
+export const useScheduleStateContext = () => {
+ const context = useContext(ScheduleStateContext);
if (context === undefined) {
- throw new Error('useSchedule must be used within a ScheduleProvider');
+ throw new Error("useSchedule must be used within a ScheduleProvider");
}
return context;
};
export const ScheduleProvider = ({ children }: PropsWithChildren) => {
- const [schedulesMap, setSchedulesMap] = useState>(dummyScheduleMap);
+ const [schedulesMap, setSchedulesMap] =
+ useState>(dummyScheduleMap);
+
+ const updateTableSchedules = useCallback(
+ (tableId: string, updater: (prev: Schedule[]) => Schedule[]) => {
+ setSchedulesMap((prev) => {
+ const currentSchedules = prev[tableId] || [];
+ const newSchedules = updater(currentSchedules);
+
+ if (currentSchedules === newSchedules) {
+ return prev;
+ }
+
+ if (
+ currentSchedules.length === newSchedules.length &&
+ currentSchedules.every((schedule, i) => schedule === newSchedules[i])
+ ) {
+ return prev;
+ }
+
+ return { ...prev, [tableId]: newSchedules };
+ });
+ },
+ []
+ );
+
+ const addTable = useCallback((newTableId: string, schedules: Schedule[]) => {
+ setSchedulesMap((prev) => ({ ...prev, [newTableId]: schedules }));
+ }, []);
+
+ const removeTable = useCallback((tableId: string) => {
+ setSchedulesMap((prev) => {
+ const newMap = { ...prev };
+ delete newMap[tableId];
+ return newMap;
+ });
+ }, []);
+
+ const actionValue = useMemo(
+ () => ({
+ setSchedulesMap,
+ updateTableSchedules,
+ addTable,
+ removeTable,
+ }),
+ [updateTableSchedules, addTable, removeTable]
+ );
+
+ const stateValue = useMemo(
+ () => ({
+ schedulesMap,
+ tableCount: Object.keys(schedulesMap).length,
+ }),
+ [schedulesMap]
+ );
return (
-
- {children}
-
+
+
+ {children}
+
+
);
};
diff --git a/src/ScheduleDndProvider.tsx b/src/ScheduleDndProvider.tsx
index ca15f527..c789161c 100644
--- a/src/ScheduleDndProvider.tsx
+++ b/src/ScheduleDndProvider.tsx
@@ -1,7 +1,14 @@
-import { DndContext, Modifier, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
-import { PropsWithChildren } from "react";
+import {
+ DndContext,
+ DragEndEvent,
+ Modifier,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import { PropsWithChildren, useCallback } from "react";
import { CellSize, DAY_LABELS } from "./constants.ts";
-import { useScheduleContext } from "./ScheduleContext.tsx";
+import { useScheduleActionContext } from "./ScheduleContext.tsx";
function createSnapModifier(): Modifier {
return ({ transform, containerNodeRect, draggingNodeRect }) => {
@@ -17,19 +24,30 @@ function createSnapModifier(): Modifier {
const maxX = containerRight - right;
const maxY = containerBottom - bottom;
-
- return ({
+ 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),
- })
+ 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()]
+const modifiers = [createSnapModifier()];
export default function ScheduleDndProvider({ children }: PropsWithChildren) {
- const { schedulesMap, setSchedulesMap } = useScheduleContext();
+ const { updateTableSchedules } = useScheduleActionContext();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -38,33 +56,41 @@ export default function ScheduleDndProvider({ children }: PropsWithChildren) {
})
);
- // 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);
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, delta } = event;
+ const { x, y } = delta;
+ const [tableId, index] = active.id.toString().split(":");
+
+ const moveDayIndex = Math.floor(x / 80);
+ const moveTimeIndex = Math.floor(y / 30);
+
+ updateTableSchedules(tableId, (prev) => {
+ const schedule = prev[Number(index)];
+ const nowDayIndex = DAY_LABELS.indexOf(
+ schedule.day as (typeof DAY_LABELS)[number]
+ );
- setSchedulesMap({
- ...schedulesMap,
- [tableId]: schedulesMap[tableId].map((targetSchedule, targetIndex) => {
- if (targetIndex !== Number(index)) {
- return { ...targetSchedule }
- }
- return {
- ...targetSchedule,
+ const updatedSchedule = {
+ ...schedule,
day: DAY_LABELS[nowDayIndex + moveDayIndex],
- range: targetSchedule.range.map(time => time + moveTimeIndex),
- }
- })
- })
- };
+ range: schedule.range.map((time) => time + moveTimeIndex),
+ };
+
+ const newSchedules = [...prev];
+ newSchedules[Number(index)] = updatedSchedule;
+ return newSchedules;
+ });
+ },
+ [updateTableSchedules]
+ );
return (
-
+
{children}
);
diff --git a/src/ScheduleTable.tsx b/src/ScheduleTable.tsx
index ea17b6a9..7ab92d04 100644
--- a/src/ScheduleTable.tsx
+++ b/src/ScheduleTable.tsx
@@ -13,17 +13,17 @@ import {
Text,
} from "@chakra-ui/react";
import { CellSize, DAY_LABELS, 분 } from "./constants.ts";
-import { Schedule } from "./types.ts";
-import { fill2, parseHnM } from "./utils.ts";
+import { Schedule, TimeInfo } from "./types.ts";
+import { parseHnM } from "./utils.ts";
import { useDndContext, useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
-import { ComponentProps, Fragment } from "react";
+import { ComponentProps, Fragment, memo, useCallback, useMemo } from "react";
interface Props {
tableId: string;
schedules: Schedule[];
- onScheduleTimeClick?: (timeInfo: { day: string, time: number }) => void;
- onDeleteButtonClick?: (timeInfo: { day: string, time: number }) => void;
+ onScheduleTimeClick?: (timeInfo: TimeInfo) => void;
+ onDeleteButtonClick?: (timeInfo: TimeInfo) => void;
}
const TIMES = [
@@ -38,32 +38,59 @@ const TIMES = [
.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 DailyLabelItem = memo(({ day }: { day: string }) => {
+ return (
+
+
+ {day}
+
+
+ );
+});
- const dndContext = useDndContext();
+const TimeLabelItem = memo(({ time }: { time: string }) => {
+ return (
+
+
+ {time}
+
+
+ );
+});
- const getActiveTableId = () => {
- const activeId = dndContext.active?.id;
- if (activeId) {
- return String(activeId).split(":")[0];
- }
- return null;
+const DailyLabelItemInner = memo(
+ ({
+ day,
+ timeIndex,
+ onScheduleTimeClick,
+ }: {
+ day: string;
+ timeIndex: number;
+ onScheduleTimeClick: (timeInfo: TimeInfo) => void;
+ }) => {
+ const handleClick = useCallback(() => {
+ onScheduleTimeClick?.({ day, time: timeIndex + 1 });
+ }, [day, timeIndex, onScheduleTimeClick]);
+ return (
+ 17 ? "gray.100" : "white"}
+ cursor="pointer"
+ _hover={{ bg: "yellow.100" }}
+ onClick={handleClick}
+ />
+ );
}
-
- const activeTableId = getActiveTableId();
-
- return (
-
+);
+const Table = memo(
+ ({
+ onScheduleTimeClick,
+ }: {
+ onScheduleTimeClick: (timeInfo: TimeInfo) => void;
+ }) => {
+ 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 })}
+ day={day}
+ timeIndex={timeIndex}
+ onScheduleTimeClick={onScheduleTimeClick ?? (() => {})}
/>
))}
))}
+ );
+ }
+);
+const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"];
- {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;
+const ScheduleText = memo(
+ ({ lecture, room }: { lecture: { title: string }; room?: string }) => (
+ <>
+
+ {lecture.title}
+
+ {room || ""}
+ >
+ )
+);
- return (
-
-
-
- {lecture.title}
- {room}
-
-
- event.stopPropagation()}>
-
-
-
- 강의를 삭제하시겠습니까?
-
-
-
-
- );
-}
+ScheduleText.displayName = "ScheduleText";
+
+const ScheduleTable = memo(
+ ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => {
+ const lectures = useMemo(
+ () => [...new Set(schedules.map(({ lecture }) => lecture.id))],
+ [schedules]
+ );
+
+ const getColor = useCallback(
+ (lectureId: string): string => {
+ return colors[lectures.indexOf(lectureId) % colors.length];
+ },
+ [lectures]
+ );
+
+ const dndContext = useDndContext();
+
+ const activeTableId = useMemo(() => {
+ const activeId = dndContext.active?.id;
+ if (activeId) {
+ return String(activeId).split(":")[0];
+ }
+ return null;
+ }, [dndContext.active?.id]);
+
+ const createDeleteHandler = useCallback(
+ (day: string, time: number) => {
+ return () => onDeleteButtonClick?.({ day, time });
+ },
+ [onDeleteButtonClick]
+ );
+
+ const handleScheduleTimeClick = useCallback(
+ (timeInfo: TimeInfo) => {
+ onScheduleTimeClick?.(timeInfo);
+ },
+ [onScheduleTimeClick]
+ );
+
+ return (
+
+
+ {schedules.map((schedule, index) => {
+ const scheduleKey = `${schedule.lecture.id}-${
+ schedule.day
+ }-${schedule.range.join("-")}`;
+ return (
+
+ );
+ })}
+
+ );
+ }
+);
+
+const DraggableSchedule = memo(
+ ({
+ 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 position = useMemo(() => {
+ const leftIndex = DAY_LABELS.indexOf(day as (typeof DAY_LABELS)[number]);
+ const topIndex = range[0] - 1;
+ const size = range.length;
+
+ return {
+ top: 40 + (topIndex * CellSize.HEIGHT + 1) + "px",
+ left: 120 + CellSize.WIDTH * leftIndex + 1 + "px",
+ width: CellSize.WIDTH - 1 + "px",
+ height: CellSize.HEIGHT * size - 1 + "px",
+ };
+ }, [day, range]);
+
+ const scheduleInfo = useMemo(() => ({ lecture, room }), [lecture, room]);
+
+ const boxStyle = useMemo(
+ () => ({
+ position: "absolute" as const,
+ left: position.left,
+ top: position.top,
+ width: position.width,
+ height: position.height,
+ bg,
+ p: 1,
+ boxSizing: "border-box" as const,
+ cursor: "pointer",
+ }),
+ [position.left, position.top, position.width, position.height, bg]
+ );
+
+ return (
+
+
+
+
+
+
+ event.stopPropagation()}>
+
+
+
+ 강의를 삭제하시겠습니까?
+
+
+
+
+ );
+ }
+);
export default ScheduleTable;
diff --git a/src/ScheduleTables.tsx b/src/ScheduleTables.tsx
index 44dbd7a7..6783e88e 100644
--- a/src/ScheduleTables.tsx
+++ b/src/ScheduleTables.tsx
@@ -1,61 +1,128 @@
import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react";
import ScheduleTable from "./ScheduleTable.tsx";
-import { useScheduleContext } from "./ScheduleContext.tsx";
+import {
+ useScheduleActionContext,
+ useScheduleStateContext,
+} from "./ScheduleContext.tsx";
import SearchDialog from "./SearchDialog.tsx";
-import { useState } from "react";
+import { memo, useCallback, useState } from "react";
+import ScheduleDndProvider from "./ScheduleDndProvider.tsx";
+import { Schedule } from "./types.ts";
+
+const ScheduleTableWrapper = memo(
+ ({
+ tableId,
+ schedules,
+ index,
+ setSearchInfo,
+ disabledRemoveButton,
+ }: {
+ tableId: string;
+ schedules: Schedule[];
+ index: number;
+ setSearchInfo: (
+ info: { tableId: string; day?: string; time?: number } | null
+ ) => void;
+ disabledRemoveButton: boolean;
+ }) => {
+ const { addTable, removeTable, updateTableSchedules } =
+ useScheduleActionContext();
+
+ const duplicate = () => {
+ const newTableId = `schedule-${Date.now()}`;
+ addTable(newTableId, [...schedules]);
+ };
+
+ const remove = (targetId: string) => {
+ removeTable(targetId);
+ };
+
+ const handleScheduleTimeClick = useCallback(
+ (timeInfo: { day: string; time: number }) =>
+ setSearchInfo({ tableId, ...timeInfo }),
+ [setSearchInfo, tableId]
+ );
+
+ const handleDeleteButtonClick = useCallback(
+ ({ day, time }: { day: string; time: number }) =>
+ updateTableSchedules(tableId, (prev) =>
+ prev.filter(
+ (schedule) => schedule.day !== day || !schedule.range.includes(time)
+ )
+ ),
+ [updateTableSchedules, tableId]
+ );
+
+ return (
+
+
+
+ 시간표 {index + 1}
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+);
export const ScheduleTables = () => {
- const { schedulesMap, setSchedulesMap } = useScheduleContext();
+ const { schedulesMap, tableCount } = useScheduleStateContext();
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 handleCloseDialog = useCallback(() => {
+ setSearchInfo(null);
+ }, []);
- const remove = (targetId: string) => {
- setSchedulesMap(prev => {
- delete prev[targetId];
- return { ...prev };
- })
- };
+ const disabledRemoveButton = tableCount === 1;
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)}/>
+ {searchInfo && (
+
+ )}
>
);
-}
+};
diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx
index 593951fc..76d28a48 100644
--- a/src/SearchDialog.tsx
+++ b/src/SearchDialog.tsx
@@ -1,39 +1,34 @@
-import { useEffect, useRef, useState } from "react";
+import { memo, useCallback, useEffect, useMemo, 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";
+import { memoizedFetch } from "./cache.ts";
+import { SearchItem } from "./components/search/SearchItem.tsx";
+import { FormInput } from "./components/common/FormInput.tsx";
+import { FormSelect } from "./components/common/FormSelect.tsx";
+import { FormCheckbox } from "./components/common/FormCheckbox.tsx";
+import { SearchMajorsInput } from "./components/search/SearchMajorsInput.tsx";
+import { SearchTimeInput } from "./components/search/SearchTimesInput.tsx";
+import { useSearchDialog } from "./components/hooks/useSearchDialog.ts";
+import { useScheduleActionContext } from "./ScheduleContext.tsx";
interface Props {
searchInfo: {
@@ -44,137 +39,160 @@ interface Props {
onClose: () => void;
}
-interface SearchOption {
- query?: string,
- grades: number[],
- days: string[],
- times: number[],
- majors: string[],
- credits?: number,
-}
+const PAGE_SIZE = 100;
-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 CREDITS_OPTIONS = [
+ { label: "전체", value: "" },
+ { label: "1학점", value: "1" },
+ { label: "2학점", value: "2" },
+ { label: "3학점", value: "3" },
];
-const PAGE_SIZE = 100;
+const GRADE_OPTIONS = [
+ { label: "전체", value: "" },
+ { label: "1학년", value: "1" },
+ { label: "2학년", value: "2" },
+ { label: "3학년", value: "3" },
+];
+
+const DAY_OPTIONS = DAY_LABELS.map((day) => ({
+ label: day,
+ value: day,
+}));
-const fetchMajors = () => axios.get('/schedules-majors.json');
-const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json');
+const BASE_URL =
+ process.env.NODE_ENV === "production" ? "/front_6th_chapter4-2" : "";
-// 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()),
-]);
+const fetchMajors = () =>
+ axios.get(`${BASE_URL}/schedules-majors.json`);
+const fetchLiberalArts = () =>
+ axios.get(`${BASE_URL}/schedules-liberal-arts.json`);
+
+const fetchAllLectures = async () =>
+ await Promise.all([
+ (console.log("API Call 1", performance.now()),
+ memoizedFetch("majors", fetchMajors)),
+ (console.log("API Call 2", performance.now()),
+ memoizedFetch("liberalArts", fetchLiberalArts)),
+ (console.log("API Call 3", performance.now()),
+ memoizedFetch("majors", fetchMajors)),
+ (console.log("API Call 4", performance.now()),
+ memoizedFetch("liberalArts", fetchLiberalArts)),
+ (console.log("API Call 5", performance.now()),
+ memoizedFetch("majors", fetchMajors)),
+ (console.log("API Call 6", performance.now()),
+ memoizedFetch("liberalArts", fetchLiberalArts)),
+ ]);
// TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요.
-const SearchDialog = ({ searchInfo, onClose }: Props) => {
- const { setSchedulesMap } = useScheduleContext();
+const SearchDialog = memo(({ searchInfo, onClose }: Props) => {
+ const { updateTableSchedules } = useScheduleActionContext();
- 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 {
+ page,
+ setPage,
+ loaderWrapperRef,
+ searchOptions,
+ setSearchOptions,
+ handlers,
+ } = useSearchDialog();
- const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => {
- setPage(1);
- setSearchOptions(({ ...searchOptions, [field]: value }));
- loaderWrapperRef.current?.scrollTo(0, 0);
- };
+ const filteredLectures = useMemo(() => {
+ const { query = "", credits, grades, days, times, majors } = searchOptions;
- const addSchedule = (lecture: Lecture) => {
- if (!searchInfo) return;
+ return lectures.filter((lecture) => {
+ if (query) {
+ const lowerQuery = query.toLowerCase();
+ if (
+ !(
+ lecture.title.toLowerCase().includes(lowerQuery) ||
+ lecture.id.toLowerCase().includes(lowerQuery)
+ )
+ )
+ return false;
+ }
- const { tableId } = searchInfo;
+ if (grades.length > 0 && !grades.includes(lecture.grade)) {
+ return false;
+ }
- const schedules = parseSchedule(lecture.schedule).map(schedule => ({
- ...schedule,
- lecture
- }));
+ if (majors.length > 0 && !majors.includes(lecture.major)) {
+ return false;
+ }
- setSchedulesMap(prev => ({
- ...prev,
- [tableId]: [...prev[tableId], ...schedules]
- }));
+ if (credits && !lecture.credits.startsWith(String(credits))) {
+ return false;
+ }
+
+ if (days.length > 0 || times.length > 0) {
+ const schedules = lecture.schedule
+ ? parseSchedule(lecture.schedule)
+ : [];
+ if (
+ days.length > 0 &&
+ !schedules.some((schedule) => days.includes(schedule.day))
+ )
+ return false;
+
+ if (
+ times.length > 0 &&
+ !schedules.some((schedule) =>
+ schedule.range.some((time) => times.includes(time))
+ )
+ )
+ return false;
+ }
- onClose();
- };
+ return true;
+ });
+ }, [lectures, searchOptions]);
+
+ const { lastPage, visibleLectures } = useMemo(
+ () => ({
+ lastPage: Math.ceil(filteredLectures.length / PAGE_SIZE),
+ visibleLectures: filteredLectures.slice(0, page * PAGE_SIZE),
+ }),
+ [filteredLectures, page]
+ );
+
+ const allMajors = useMemo(
+ () =>
+ lectures.length > 0
+ ? [...new Set(lectures.map((lecture) => lecture.major))]
+ : [],
+ [lectures]
+ );
+
+ const addSchedule = useCallback(
+ (lecture: Lecture) => {
+ if (!searchInfo) return;
+
+ const { tableId } = searchInfo;
+
+ const schedules = parseSchedule(lecture.schedule).map((schedule) => ({
+ ...schedule,
+ lecture,
+ }));
+
+ updateTableSchedules(tableId, (prev) => [...prev, ...schedules]);
+
+ onClose();
+ },
+ [onClose, searchInfo, updateTableSchedules]
+ );
useEffect(() => {
const start = performance.now();
- console.log('API 호출 시작: ', start)
- fetchAllLectures().then(results => {
+ 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));
- })
+ console.log("모든 API 호출 완료 ", end);
+ console.log("API 호출에 걸린 시간(ms): ", end - start);
+ setLectures(results.flatMap((result) => result.data));
+ });
}, []);
useEffect(() => {
@@ -186,9 +204,9 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => {
}
const observer = new IntersectionObserver(
- entries => {
+ (entries) => {
if (entries[0].isIntersecting) {
- setPage(prevPage => Math.min(lastPage, prevPage + 1));
+ setPage((prevPage) => Math.min(lastPage, prevPage + 1));
}
},
{ threshold: 0, root: $loaderWrapper }
@@ -197,141 +215,69 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => {
observer.observe($loader);
return () => observer.unobserve($loader);
- }, [lastPage]);
+ }, [lastPage, loaderWrapperRef, setPage]);
useEffect(() => {
- setSearchOptions(prev => ({
+ setSearchOptions((prev) => ({
...prev,
days: searchInfo?.day ? [searchInfo.day] : [],
times: searchInfo?.time ? [searchInfo.time] : [],
- }))
+ }));
setPage(1);
- }, [searchInfo]);
+ }, [searchInfo, setPage, setSearchOptions]);
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}개
-
+ 검색결과: {filteredLectures.length}개
@@ -351,21 +297,20 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => {
{visibleLectures.map((lecture, index) => (
-
- | {lecture.id} |
- {lecture.grade} |
- {lecture.title} |
- {lecture.credits} |
- |
- |
-
-
- |
-
+
))}
-
+ {
+ loaderRef.current = e;
+ }}
+ h="20px"
+ />
@@ -373,6 +318,6 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => {
);
-};
+});
-export default SearchDialog;
\ No newline at end of file
+export default SearchDialog;
diff --git a/src/cache.ts b/src/cache.ts
new file mode 100644
index 00000000..2fc7a720
--- /dev/null
+++ b/src/cache.ts
@@ -0,0 +1,10 @@
+const cache = new Map>();
+
+export const memoizedFetch = (key: string, fn: () => Promise) => {
+ if (cache.has(key)) {
+ return cache.get(key) as Promise;
+ }
+ const promise = fn();
+ cache.set(key, promise);
+ return promise;
+};
diff --git a/src/components/common/FormCheckbox.tsx b/src/components/common/FormCheckbox.tsx
new file mode 100644
index 00000000..f86dc39f
--- /dev/null
+++ b/src/components/common/FormCheckbox.tsx
@@ -0,0 +1,35 @@
+import {
+ Checkbox,
+ CheckboxGroup,
+ FormControl,
+ FormLabel,
+ HStack,
+} from "@chakra-ui/react";
+import { memo } from "react";
+
+interface FormCheckboxProp {
+ label?: string;
+ value?: (string | number)[] | undefined;
+ onChange: (value: (string | number)[]) => void;
+ options: { label?: string; value: string | number }[];
+}
+
+export const FormCheckbox = memo(
+ ({ value, label, onChange, options }: FormCheckboxProp) => {
+ return (
+
+ {label}
+
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ );
+ }
+);
+FormCheckbox.displayName = "FormCheckbox";
diff --git a/src/components/common/FormInput.tsx b/src/components/common/FormInput.tsx
new file mode 100644
index 00000000..42fbe6b6
--- /dev/null
+++ b/src/components/common/FormInput.tsx
@@ -0,0 +1,20 @@
+import { FormControl, FormLabel, Input } from "@chakra-ui/react";
+import { ComponentPropsWithRef, memo } from "react";
+
+interface FormInputProp extends ComponentPropsWithRef<"input"> {
+ label: string;
+ value: string | undefined;
+ onChange: React.ChangeEventHandler;
+}
+
+export const FormInput = memo(
+ ({ label, value, onChange, placeholder }: FormInputProp) => {
+ return (
+
+ {label}
+
+
+ );
+ }
+);
+FormInput.displayName = "FormInput";
diff --git a/src/components/common/FormSelect.tsx b/src/components/common/FormSelect.tsx
new file mode 100644
index 00000000..d167ecd7
--- /dev/null
+++ b/src/components/common/FormSelect.tsx
@@ -0,0 +1,27 @@
+import { FormControl, FormLabel, Select } from "@chakra-ui/react";
+import { ComponentPropsWithRef, memo } from "react";
+
+interface FormSelectProp extends ComponentPropsWithRef<"select"> {
+ label?: string;
+ value: number | string | string[] | undefined;
+ onChange: React.ChangeEventHandler;
+ options: { label: string; value: string }[];
+}
+
+export const FormSelect = memo(
+ ({ label, value, onChange, options }: FormSelectProp) => {
+ return (
+
+ {label}
+
+
+ );
+ }
+);
+FormSelect.displayName = "FormSelect";
diff --git a/src/components/hooks/useSearchDialog.ts b/src/components/hooks/useSearchDialog.ts
new file mode 100644
index 00000000..27bf83f3
--- /dev/null
+++ b/src/components/hooks/useSearchDialog.ts
@@ -0,0 +1,79 @@
+import { useCallback, useMemo, useRef, useState } from "react";
+
+export interface SearchOption {
+ query?: string;
+ grades: number[];
+ days: string[];
+ times: number[];
+ majors: string[];
+ credits?: number;
+}
+
+export const useSearchDialog = () => {
+ const [page, setPage] = useState(1);
+ const loaderWrapperRef = useRef(null);
+ const [searchOptions, setSearchOptions] = useState({
+ query: "",
+ grades: [],
+ days: [],
+ times: [],
+ majors: [],
+ });
+
+ const resetPageAndScroll = useCallback(() => {
+ setPage(1);
+ loaderWrapperRef.current?.scrollTo(0, 0);
+ }, []);
+
+ const updateSearchOptions = useCallback(
+ (field: keyof SearchOption, value: unknown) => {
+ setSearchOptions((prev) => ({ ...prev, [field]: value }));
+ resetPageAndScroll();
+ },
+ [resetPageAndScroll]
+ );
+
+ const deleteSearchOption = useCallback(
+ (field: keyof SearchOption, value: unknown) => {
+ setSearchOptions((prev) => ({
+ ...prev,
+ [field]: (Array.isArray(prev[field]) ? prev[field] : []).filter(
+ (v: string | number) => v !== value
+ ),
+ }));
+ resetPageAndScroll();
+ },
+ [resetPageAndScroll]
+ );
+
+ const handlers = useMemo(
+ () => ({
+ handleQueryChange: (e: React.ChangeEvent) =>
+ updateSearchOptions("query", e.target.value),
+ handleCreditsChange: (e: React.ChangeEvent) =>
+ updateSearchOptions("credits", e.target.value),
+ handleGradesChange: (value: Array) =>
+ updateSearchOptions("grades", value),
+ handleDaysChange: (value: Array) =>
+ updateSearchOptions("days", value),
+ handleTimesChange: (value: Array) =>
+ updateSearchOptions("times", value.map(Number)),
+ handleMajorsChange: (value: Array) =>
+ updateSearchOptions("majors", value),
+ handleTimeTagClose: (value: number | string) =>
+ deleteSearchOption("times", Number(value)),
+ handleMajorTagClose: (value: number | string) =>
+ deleteSearchOption("majors", value),
+ }),
+ [updateSearchOptions, deleteSearchOption]
+ );
+
+ return {
+ page,
+ setPage,
+ loaderWrapperRef,
+ searchOptions,
+ setSearchOptions,
+ handlers,
+ };
+};
diff --git a/src/components/search/SearchItem.tsx b/src/components/search/SearchItem.tsx
new file mode 100644
index 00000000..10e3cc00
--- /dev/null
+++ b/src/components/search/SearchItem.tsx
@@ -0,0 +1,33 @@
+import { memo } from "react";
+import { Lecture } from "../../types";
+import { Button, Td, Tr } from "@chakra-ui/react";
+
+export const SearchItem = memo(
+ ({
+ addSchedule,
+ ...lecture
+ }: Lecture & { addSchedule: (lecture: Lecture) => void }) => {
+ const { id, grade, title, credits, major, schedule } = lecture;
+ return (
+
+ | {id} |
+ {grade} |
+ {title} |
+ {credits} |
+ |
+ |
+
+
+ |
+
+ );
+ }
+);
+
+SearchItem.displayName = "SearchItem";
diff --git a/src/components/search/SearchMajorsInput.tsx b/src/components/search/SearchMajorsInput.tsx
new file mode 100644
index 00000000..3385ab54
--- /dev/null
+++ b/src/components/search/SearchMajorsInput.tsx
@@ -0,0 +1,65 @@
+import {
+ Box,
+ Checkbox,
+ CheckboxGroup,
+ FormControl,
+ FormLabel,
+ Stack,
+ Tag,
+ TagCloseButton,
+ TagLabel,
+ Wrap,
+} from "@chakra-ui/react";
+import { memo } from "react";
+
+export const SearchMajorsInput = memo(
+ ({
+ selectedMajors,
+ allMajors,
+ onChangeCheckbox,
+ onCloseTag,
+ }: {
+ selectedMajors: string[] | undefined;
+ allMajors: string[];
+ onChangeCheckbox: (value: Array) => void;
+ onCloseTag: (major: string) => void;
+ }) => {
+ return (
+
+ 전공
+
+
+ {selectedMajors?.map((major) => (
+
+ {major.split("").pop()}
+ onCloseTag(major)} />
+
+ ))}
+
+
+ {allMajors.map((major) => (
+
+
+ {major.replace(//gi, " ")}
+
+
+ ))}
+
+
+
+ );
+ }
+);
+SearchMajorsInput.displayName = "SearchMajorsInput";
diff --git a/src/components/search/SearchTimesInput.tsx b/src/components/search/SearchTimesInput.tsx
new file mode 100644
index 00000000..8eb5b686
--- /dev/null
+++ b/src/components/search/SearchTimesInput.tsx
@@ -0,0 +1,92 @@
+import {
+ Box,
+ Checkbox,
+ CheckboxGroup,
+ FormControl,
+ FormLabel,
+ Stack,
+ Tag,
+ TagCloseButton,
+ TagLabel,
+ Wrap,
+} from "@chakra-ui/react";
+import { memo } from "react";
+
+export const SearchTimeInput = memo(
+ ({
+ selectedTimes,
+ onChangeCheckbox,
+ onCloseTag,
+ }: {
+ selectedTimes: number[] | undefined;
+ onChangeCheckbox: (value: Array) => void;
+ onCloseTag: (major: number) => void;
+ }) => {
+ return (
+
+ 시간
+
+
+ {selectedTimes
+ ?.sort((a, b) => a - b)
+ .map((time) => (
+
+ {time}교시
+ onCloseTag(time)} />
+
+ ))}
+
+
+ {TIME_SLOTS.map(({ id, label }) => (
+
+
+ {id}교시({label})
+
+
+ ))}
+
+
+
+ );
+ }
+);
+SearchTimeInput.displayName = "SearchTimeInput";
+
+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" },
+];
diff --git a/src/types.ts b/src/types.ts
index 16118bf9..288fd3a0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -8,8 +8,13 @@ export interface Lecture {
}
export interface Schedule {
- lecture: Lecture
+ lecture: Lecture;
day: string;
- range: number[]
+ range: number[];
room?: string;
}
+
+export interface TimeInfo {
+ day: string;
+ time: number;
+}
diff --git a/vite.config.ts b/vite.config.ts
index 1cdac555..6b4431a7 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,20 +1,33 @@
-import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config';
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react-swc';
+import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react-swc";
+import { resolve } from "path";
+
+const base =
+ process.env.NODE_ENV === "production" ? "/front_6th_chapter4-2/" : "";
export default mergeConfig(
defineConfig({
plugins: [react()],
+ base,
+ build: {
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, "index.html"),
+ 404: resolve(__dirname, "404.html"),
+ },
+ },
+ },
}),
defineTestConfig({
test: {
globals: true,
- environment: 'jsdom',
- setupFiles: './src/setupTests.ts',
+ environment: "jsdom",
+ setupFiles: "./src/setupTests.ts",
coverage: {
reportsDirectory: "./.coverage",
- reporter: ['lcov', 'json', 'json-summary']
+ reporter: ["lcov", "json", "json-summary"],
},
},
})
-)
+);