diff --git a/package.json b/package.json index a4832bb..249d904 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "happy-dom": "^20.4.0", - "husky": "^8.0.0", + "husky": "^9.1.7", "lint-staged": "^16.2.7", "pg": "^8.18.0", "prettier": "^3.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a02dab..349381e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,8 +133,8 @@ importers: specifier: ^20.4.0 version: 20.4.0 husky: - specifier: ^8.0.0 - version: 8.0.3 + specifier: ^9.1.7 + version: 9.1.7 lint-staged: specifier: ^16.2.7 version: 16.2.7 @@ -164,26 +164,14 @@ importers: version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@20.19.27)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.16(@types/node@20.19.27)(happy-dom@20.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages: - '@acemir/cssom@0.9.30': - resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@asamuzakjp/css-color@4.1.1': - resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} - - '@asamuzakjp/dom-selector@6.7.6': - resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} - - '@asamuzakjp/nwsapi@2.3.9': - resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -275,38 +263,6 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@csstools/color-helpers@5.1.0': - resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} - engines: {node: '>=18'} - - '@csstools/css-calc@2.1.4': - resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-color-parser@3.1.0': - resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-parser-algorithms@3.0.5': - resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-syntax-patches-for-csstree@1.0.22': - resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} - engines: {node: '>=18'} - - '@csstools/css-tokenizer@3.0.4': - resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} - engines: {node: '>=18'} - '@embedpdf/core@2.4.0': resolution: {integrity: sha512-IvA50UNC3UeMRTBx5qH9CApBziXZrXWV9xnocNbqdUWJotU71+SnELKkkDiD//Qr2o9qwwuuMVGPSgck+4hzYg==} peerDependencies: @@ -599,15 +555,6 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@exodus/bytes@1.8.0': - resolution: {integrity: sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - '@exodus/crypto': ^1.0.0-rc.4 - peerDependenciesMeta: - '@exodus/crypto': - optional: true - '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -669,89 +616,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -824,24 +787,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.1': resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.1': resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.1': resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.1': resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==} @@ -1682,66 +1649,79 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -1834,24 +1814,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2066,41 +2050,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2204,10 +2196,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -2318,9 +2306,6 @@ packages: resolution: {integrity: sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==} hasBin: true - bidi-js@1.0.3: - resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2425,24 +2410,12 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - cssstyle@5.3.7: - resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} - engines: {node: '>=20'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - data-urls@6.0.0: - resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} - engines: {node: '>=20'} - data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2481,9 +2454,6 @@ packages: supports-color: optional: true - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2560,10 +2530,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -2939,21 +2905,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - html-encoding-sniffer@6.0.0: - resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - husky@8.0.3: - resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==} - engines: {node: '>=14'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} hasBin: true ignore@5.3.2: @@ -3057,9 +3011,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -3124,15 +3075,6 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsdom@27.4.0: - resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - canvas: ^3.0.0 - peerDependenciesMeta: - canvas: - optional: true - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3209,24 +3151,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -3277,10 +3223,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3303,9 +3245,6 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3449,9 +3388,6 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse5@8.0.0: - resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3671,10 +3607,6 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3722,10 +3654,6 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3900,9 +3828,6 @@ packages: resolution: {integrity: sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==} engines: {node: '>=18'} - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3932,25 +3857,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tldts-core@7.0.19: - resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} - - tldts@7.0.19: - resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} - hasBin: true - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - tough-cookie@6.0.0: - resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} - engines: {node: '>=16'} - - tr46@6.0.0: - resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} - engines: {node: '>=20'} - ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -3974,6 +3884,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -4178,26 +4089,10 @@ packages: typescript: optional: true - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - webidl-conversions@8.0.1: - resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} - engines: {node: '>=20'} - whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - - whatwg-url@15.1.0: - resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} - engines: {node: '>=20'} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4256,13 +4151,6 @@ packages: utf-8-validate: optional: true - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - xmlhttprequest-ssl@2.1.2: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} @@ -4301,32 +4189,8 @@ packages: snapshots: - '@acemir/cssom@0.9.30': - optional: true - '@alloc/quick-lru@5.2.0': {} - '@asamuzakjp/css-color@4.1.1': - dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 11.2.4 - optional: true - - '@asamuzakjp/dom-selector@6.7.6': - dependencies: - '@asamuzakjp/nwsapi': 2.3.9 - bidi-js: 1.0.3 - css-tree: 3.1.0 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.4 - optional: true - - '@asamuzakjp/nwsapi@2.3.9': - optional: true - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4445,34 +4309,6 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@csstools/color-helpers@5.1.0': - optional: true - - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - optional: true - - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - optional: true - - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-tokenizer': 3.0.4 - optional: true - - '@csstools/css-syntax-patches-for-csstree@1.0.22': - optional: true - - '@csstools/css-tokenizer@3.0.4': - optional: true - '@embedpdf/core@2.4.0(preact@10.28.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.49.1)(vue@3.5.27(typescript@5.9.3))': dependencies: '@embedpdf/engines': 2.4.0(preact@10.28.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.49.1)(vue@3.5.27(typescript@5.9.3)) @@ -4701,9 +4537,6 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.8.0': - optional: true - '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -6259,9 +6092,6 @@ snapshots: acorn@8.15.0: {} - agent-base@7.1.4: - optional: true - ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -6386,11 +6216,6 @@ snapshots: baseline-browser-mapping@2.9.12: {} - bidi-js@1.0.3: - dependencies: - require-from-string: 2.0.2 - optional: true - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6493,30 +6318,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-tree@3.1.0: - dependencies: - mdn-data: 2.12.2 - source-map-js: 1.2.1 - optional: true - - cssstyle@5.3.7: - dependencies: - '@asamuzakjp/css-color': 4.1.1 - '@csstools/css-syntax-patches-for-csstree': 1.0.22 - css-tree: 3.1.0 - lru-cache: 11.2.4 - optional: true - csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} - data-urls@6.0.0: - dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 15.1.0 - optional: true - data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6547,9 +6352,6 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js@10.6.0: - optional: true - deep-is@0.1.4: {} define-data-property@1.1.4: @@ -6633,9 +6435,6 @@ snapshots: entities@4.5.0: {} - entities@6.0.1: - optional: true - entities@7.0.1: {} environment@1.1.0: {} @@ -7171,30 +6970,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - html-encoding-sniffer@6.0.0: - dependencies: - '@exodus/bytes': 1.8.0 - transitivePeerDependencies: - - '@exodus/crypto' - optional: true - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - optional: true - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - optional: true - - husky@8.0.3: {} + husky@9.1.7: {} ignore@5.3.2: {} @@ -7312,9 +7088,6 @@ snapshots: is-number@7.0.0: {} - is-potential-custom-element-name@1.0.1: - optional: true - is-reference@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -7381,35 +7154,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@27.4.0: - dependencies: - '@acemir/cssom': 0.9.30 - '@asamuzakjp/dom-selector': 6.7.6 - '@exodus/bytes': 1.8.0 - cssstyle: 5.3.7 - data-urls: 6.0.0 - decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - parse5: 8.0.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 6.0.0 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 8.0.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 15.1.0 - ws: 8.19.0 - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - '@exodus/crypto' - - bufferutil - - supports-color - - utf-8-validate - optional: true - jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -7538,9 +7282,6 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@11.2.4: - optional: true - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7559,9 +7300,6 @@ snapshots: math-intrinsics@1.1.0: {} - mdn-data@2.12.2: - optional: true - merge2@1.4.1: {} micromatch@4.0.8: @@ -7704,11 +7442,6 @@ snapshots: dependencies: callsites: 3.1.0 - parse5@8.0.0: - dependencies: - entities: 6.0.1 - optional: true - path-exists@4.0.0: {} path-key@3.1.1: {} @@ -7958,9 +7691,6 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 - require-from-string@2.0.2: - optional: true - resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8040,11 +7770,6 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - optional: true - scheduler@0.27.0: {} semver@6.3.1: {} @@ -8308,9 +8033,6 @@ snapshots: magic-string: 0.30.21 zimmerframe: 1.1.4 - symbol-tree@3.2.4: - optional: true - synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9 @@ -8332,28 +8054,10 @@ snapshots: tinyrainbow@3.0.3: {} - tldts-core@7.0.19: - optional: true - - tldts@7.0.19: - dependencies: - tldts-core: 7.0.19 - optional: true - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - tough-cookie@6.0.0: - dependencies: - tldts: 7.0.19 - optional: true - - tr46@6.0.0: - dependencies: - punycode: 2.3.1 - optional: true - ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -8547,7 +8251,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.16(@types/node@20.19.27)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.16(@types/node@20.19.27)(happy-dom@20.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.16 '@vitest/mocker': 4.0.16(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) @@ -8572,7 +8276,6 @@ snapshots: optionalDependencies: '@types/node': 20.19.27 happy-dom: 20.4.0 - jsdom: 27.4.0 transitivePeerDependencies: - jiti - less @@ -8596,25 +8299,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - optional: true - - webidl-conversions@8.0.1: - optional: true - whatwg-mimetype@3.0.0: {} - whatwg-mimetype@4.0.0: - optional: true - - whatwg-url@15.1.0: - dependencies: - tr46: 6.0.0 - webidl-conversions: 8.0.1 - optional: true - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -8677,12 +8363,6 @@ snapshots: ws@8.19.0: {} - xml-name-validator@5.0.0: - optional: true - - xmlchars@2.2.0: - optional: true - xmlhttprequest-ssl@2.1.2: {} xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cde4117..b84c0e0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,14 @@ packages: - . +allowBuilds: + '@prisma/client': true + '@prisma/engines': true + esbuild: true + prisma: true + sharp: true + unrs-resolver: true + nodeLinker: hoisted onlyBuiltDependencies: diff --git a/prisma/migrations/20260712000000_add_question_slide_context/migration.sql b/prisma/migrations/20260712000000_add_question_slide_context/migration.sql new file mode 100644 index 0000000..72cf261 --- /dev/null +++ b/prisma/migrations/20260712000000_add_question_slide_context/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Question" ADD COLUMN "slidePageIndex" INTEGER; +ALTER TABLE "Question" ADD COLUMN "slideSetId" TEXT; + +-- CreateIndex +CREATE INDEX "Question_slideSetId_idx" ON "Question"("slideSetId"); + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question_slideSetId_fkey" FOREIGN KEY ("slideSetId") REFERENCES "SlideSet"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e83e89c..5d33f74 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -122,34 +122,39 @@ model SlideSet { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - session Session @relation(fields: [sessionId], references: [id]) - uploader User @relation("SlideSetUploader", fields: [uploadedBy], references: [id]) + session Session @relation(fields: [sessionId], references: [id]) + uploader User @relation("SlideSetUploader", fields: [uploadedBy], references: [id]) + questions Question[] @@index([sessionId]) @@index([uploadedBy]) } model Question { - id String @id @default(cuid()) - sessionId String - authorId String? - content String - isAnonymous Boolean @default(false) - visibility Visibility @default(PUBLIC) - status QuestionStatus @default(OPEN) - upvoteCount Int @default(0) - createdAt DateTime @default(now()) - - session Session @relation(fields: [sessionId], references: [id]) - author User? @relation(fields: [authorId], references: [id]) - answers Answer[] - upvotes QuestionUpvote[] + id String @id @default(cuid()) + sessionId String + authorId String? + content String + isAnonymous Boolean @default(false) + visibility Visibility @default(PUBLIC) + status QuestionStatus @default(OPEN) + upvoteCount Int @default(0) + slidePageIndex Int? + slideSetId String? + createdAt DateTime @default(now()) + + session Session @relation(fields: [sessionId], references: [id]) + author User? @relation(fields: [authorId], references: [id]) + slideSet SlideSet? @relation(fields: [slideSetId], references: [id], onDelete: SetNull) + answers Answer[] + upvotes QuestionUpvote[] @@index([sessionId]) @@index([authorId]) @@index([status]) @@index([visibility]) @@index([createdAt]) + @@index([slideSetId]) } model Answer { diff --git a/src/app/api/sessions/[sessionId]/questions/route.ts b/src/app/api/sessions/[sessionId]/questions/route.ts index 56277a3..56a6345 100644 --- a/src/app/api/sessions/[sessionId]/questions/route.ts +++ b/src/app/api/sessions/[sessionId]/questions/route.ts @@ -11,6 +11,7 @@ import { validateQuestionContent, validateVisibility, validateSessionForQuestions, + validateQuestionSlideContext, } from "@/lib/questionValidation"; import { getSessionMembership } from "@/lib/sessionService"; @@ -26,6 +27,8 @@ interface QuestionCreateBody { content: string; visibility?: "PUBLIC" | "INSTRUCTOR_ONLY"; isAnonymous?: boolean; + slidePageIndex?: number; + slideSetId?: string; } // --------------------------------------------------------------------------- @@ -116,6 +119,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { hasAcceptedAnswer: q.answers.length > 0, acceptedAnswerId: q.answers[0]?.id ?? null, createdAt: q.createdAt, + slidePageIndex: q.slidePageIndex, + slideSetId: q.slideSetId, author: q.isAnonymous && !canRevealAnonymous ? null : q.author, })); @@ -157,6 +162,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { * - authorId: string (required) * - visibility: "PUBLIC" | "INSTRUCTOR_ONLY" (optional, defaults to PUBLIC) * - isAnonymous: boolean (optional, defaults to false) + * - slidePageIndex: number (optional, 0-based page the asker was viewing) + * - slideSetId: string (optional, must belong to the session) * * Validations: * 1. Content length bounds @@ -209,6 +216,19 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: sessionValidation.error }, { status: statusCode }); } + // 6b. Validate slide context — reject with 400 when provided but invalid + const slideValidation = await validateQuestionSlideContext( + sessionId, + body.slidePageIndex, + body.slideSetId + ); + const hasSlideInput = body.slidePageIndex != null || body.slideSetId != null; + if (!slideValidation.valid && hasSlideInput) { + return NextResponse.json({ error: slideValidation.error }, { status: 400 }); + } + const slidePageIndex = slideValidation.slidePageIndex; + const slideSetId = slideValidation.slideSetId; + // 7. Create the question and record activity on the session atomically const [question] = await prisma.$transaction([ prisma.question.create({ @@ -218,6 +238,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { content: body.content.trim(), visibility: body.visibility ?? "PUBLIC", isAnonymous: body.isAnonymous ?? false, + slidePageIndex, + slideSetId, }, include: { author: { @@ -244,6 +266,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { isAnonymous: question.isAnonymous, upvoteCount: question.upvoteCount, createdAt: question.createdAt, + slidePageIndex: question.slidePageIndex, + slideSetId: question.slideSetId, author: question.isAnonymous ? null : question.author, }, { status: 201 } diff --git a/src/app/room/RoomContext.ts b/src/app/room/RoomContext.ts index a06c9a7..09b6a73 100644 --- a/src/app/room/RoomContext.ts +++ b/src/app/room/RoomContext.ts @@ -1,24 +1,73 @@ -import { createContext, useContext } from "react"; +import { createContext, useContext, useSyncExternalStore } from "react"; +import type { MutableRefObject } from "react"; import type { Socket } from "socket.io-client"; import type { ClientToServerEvents, ServerToClientEvents } from "@/socket/types"; import type { Role } from "@/utils/types"; +export interface SlideContextSnapshot { + slidePageIndex: number | null; + slideSetId: string | null; +} + +export interface SlideNavigationTarget { + slidePageIndex: number; + slideSetId: string; +} + export interface RoomContextValue { socket: Socket | null; sessionId: string; userId: string; role: Role; sessionTitle: string; + /** Latest viewer slide position — read `.current` in handlers (does not re-render). */ + slideContextRef: MutableRefObject; + /** Slide the user was on before jumping via a question badge, if any. */ + slideReturnTarget: SlideContextSnapshot | null; + /** Navigate the slide viewer to a question's slide context. */ + navigateToQuestionSlide: (target: SlideNavigationTarget) => void; + /** Return to the slide position saved before the last question-badge jump. */ + goBackToPreviousSlide: () => void; } +const defaultSlideContextRef: MutableRefObject = { + current: { slidePageIndex: null, slideSetId: null }, +}; + export const RoomContext = createContext({ socket: null, sessionId: "", userId: "", role: "STUDENT", sessionTitle: "", + slideContextRef: defaultSlideContextRef, + slideReturnTarget: null, + navigateToQuestionSlide: () => {}, + goBackToPreviousSlide: () => {}, }); export function useRoom() { return useContext(RoomContext); } + +/** Slide label for ChatInput only — avoids re-rendering the chat list on page flips. */ +let slideUiSnapshot: SlideContextSnapshot = { slidePageIndex: null, slideSetId: null }; +const slideUiListeners = new Set<() => void>(); + +export function publishSlideContext(ctx: SlideContextSnapshot) { + slideUiSnapshot = ctx; + slideUiListeners.forEach((l) => l()); +} + +export function useSlideContext() { + return useSyncExternalStore( + (onStoreChange) => { + slideUiListeners.add(onStoreChange); + return () => { + slideUiListeners.delete(onStoreChange); + }; + }, + () => slideUiSnapshot, + () => slideUiSnapshot + ); +} diff --git a/src/app/room/classChat/ChatHeader.tsx b/src/app/room/classChat/ChatHeader.tsx index 003f5bf..1408316 100644 --- a/src/app/room/classChat/ChatHeader.tsx +++ b/src/app/room/classChat/ChatHeader.tsx @@ -2,10 +2,11 @@ import { Input } from "@/components/ui/input"; import { useContext, useState } from "react"; -import { PanelRightClose, Users, GraduationCap, Search, X, UserPlus } from "lucide-react"; +import { PanelRightClose, Users, GraduationCap, Search, X, UserPlus, Undo2 } from "lucide-react"; import ManageTAsModal from "./ManageTAsModal"; import { useMediaQuery } from "@/hooks/use-media-query"; import { SlideUpdateContext } from "../SlideUpdateContext"; +import { useRoom } from "../RoomContext"; import type { Role } from "@/utils/types"; interface ChatHeaderProps { @@ -48,8 +49,6 @@ function SlideToggle() { ); } -import { useRoom } from "../RoomContext"; - export default function ChatHeader({ role, answerMode, @@ -57,7 +56,8 @@ export default function ChatHeader({ searchQuery, onSearchChange, }: ChatHeaderProps) { - const { sessionTitle } = useRoom(); + const { sessionTitle, slideReturnTarget, goBackToPreviousSlide } = useRoom(); + const { isSlidesVisible } = useContext(SlideUpdateContext); const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [showTAModal, setShowTAModal] = useState(false); @@ -106,6 +106,15 @@ export default function ChatHeader({ <>
+ {slideReturnTarget?.slidePageIndex != null && !isSlidesVisible && ( + + )} {sessionTitle && (

{sessionTitle} diff --git a/src/app/room/classChat/ChatInput.tsx b/src/app/room/classChat/ChatInput.tsx index 68eb018..014057e 100644 --- a/src/app/room/classChat/ChatInput.tsx +++ b/src/app/room/classChat/ChatInput.tsx @@ -2,17 +2,20 @@ import { useState } from "react"; import { Textarea } from "@/components/ui/textarea"; -import { Ghost, User, Send } from "lucide-react"; +import { Ghost, User, Send, Presentation } from "lucide-react"; +import { useSlideContext } from "../RoomContext"; const MIN_LENGTH = 5; interface ChatInputProps { - onSubmit: (content: string, isAnonymous: boolean) => void; + onSubmit: (content: string, isAnonymous: boolean, includeSlideContext: boolean) => void; disabled?: boolean; serverError?: string | null; onClearError?: () => void; isAnonymous: boolean; onAnonymousChange: (val: boolean) => void; + includeSlideContext: boolean; + onIncludeSlideContextChange: (val: boolean) => void; } export default function ChatInput({ @@ -22,11 +25,16 @@ export default function ChatInput({ onClearError, isAnonymous, onAnonymousChange, + includeSlideContext, + onIncludeSlideContextChange, }: ChatInputProps) { + const slideContext = useSlideContext(); const [content, setContent] = useState(""); const [localError, setLocalError] = useState(null); const error = serverError ?? localError; + const slideContextAvailable = + slideContext.slidePageIndex !== null && slideContext.slideSetId !== null; const handleChange = (e: React.ChangeEvent) => { setContent(e.target.value); @@ -43,7 +51,7 @@ export default function ChatInput({ return; } setLocalError(null); - onSubmit(trimmed, isAnonymous); + onSubmit(trimmed, isAnonymous, includeSlideContext && slideContextAvailable); setContent(""); }; @@ -71,7 +79,7 @@ export default function ChatInput({ error ? "border-red-400 bg-red-50" : "" }`} /> -
+
+ {slideContextAvailable && ( + + )} {error &&

{error}

}
); diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index c9633e6..d20a98c 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -3,9 +3,18 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { MessageCircle, CheckCircle2, Undo2, Trash2, ChevronDown, ChevronUp } from "lucide-react"; +import { + MessageCircle, + CheckCircle2, + Undo2, + Trash2, + ChevronDown, + ChevronUp, + Presentation, +} from "lucide-react"; import { Question, Post } from "@/utils/types"; import { UpvoteButton, renderUsername } from "./PostUtils"; +import { useRoom } from "../../RoomContext"; // --------------------------------------------------------------------------- // Reply composer @@ -131,6 +140,7 @@ export default function QuestionPost({ const [isReplying, setIsReplying] = useState(false); const [threadState, setThreadState] = useState("default"); const [confirmingDelete, setConfirmingDelete] = useState(false); + const { navigateToQuestionSlide } = useRoom(); /** Parent (socket/API) is the source of truth; optimistic updates flow through `post`. */ const resolved = post.isResolved; @@ -177,6 +187,23 @@ export default function QuestionPost({ {renderUsername(post.user, post.isAnonymous)} {post.timestamp} + {post.slidePageIndex != null && post.slideSetId && ( + + )} + {hasAnyReplies && ( ret) return cur <= ret; + if (jump < ret) return cur >= ret; + return true; +} + // --------------------------------------------------------------------------- // Chat history export // --------------------------------------------------------------------------- @@ -139,6 +160,68 @@ function RoomInner() { const [showEndModal, setShowEndModal] = useState(false); const [endingSession, setEndingSession] = useState(false); const chatHistoryRef = useRef([]); + const slideContextRef = useRef({ + slidePageIndex: null, + slideSetId: null, + }); + const slideJumpTargetRef = useRef(null); + + const [slideReturnTarget, setSlideReturnTarget] = useState(null); + const [slideNavTarget, setSlideNavTarget] = useState(null); + + const handleSlideContextChange = useCallback((ctx: SlideContextSnapshot) => { + slideContextRef.current = ctx; + publishSlideContext(ctx); + + setSlideReturnTarget((prev) => { + if (!prev?.slidePageIndex || !prev.slideSetId) return prev; + const jump = slideJumpTargetRef.current; + if (!jump) { + return ctx.slidePageIndex === prev.slidePageIndex && ctx.slideSetId === prev.slideSetId + ? null + : prev; + } + if (!passedSlideReturnTarget(prev, jump, ctx)) return prev; + slideJumpTargetRef.current = null; + return null; + }); + }, []); + + const navigateToQuestionSlide = useCallback( + (target: SlideNavigationTarget) => { + const current = slideContextRef.current; + const isDifferent = + current.slidePageIndex !== target.slidePageIndex || + current.slideSetId !== target.slideSetId; + + if ( + isDifferent && + current.slidePageIndex !== null && + current.slideSetId !== null && + slideReturnTarget === null + ) { + setSlideReturnTarget({ ...current }); + slideJumpTargetRef.current = { + slidePageIndex: target.slidePageIndex, + slideSetId: target.slideSetId, + }; + } + + setIsSlidesVisible(true); + setSlideNavTarget({ + slidePageIndex: target.slidePageIndex, + slideSetId: target.slideSetId, + }); + }, + [slideReturnTarget] + ); + + const goBackToPreviousSlide = useCallback(() => { + if (slideReturnTarget?.slidePageIndex == null || !slideReturnTarget?.slideSetId) return; + setSlideNavTarget({ ...slideReturnTarget }); + slideJumpTargetRef.current = null; + setSlideReturnTarget(null); + }, [slideReturnTarget]); const [socket, setSocket] = useState | null>( null @@ -295,7 +378,19 @@ function RoomInner() { const isProfessor = role === "PROFESSOR"; return ( - +
{isSlidesVisible ? ( @@ -309,6 +404,8 @@ function RoomInner() { setShowEndModal(true) : undefined} + onSlideContextChange={handleSlideContextChange} + slideNavTarget={slideNavTarget} /> diff --git a/src/app/room/slideViewer.tsx b/src/app/room/slideViewer.tsx index fc930b9..e92fe9c 100644 --- a/src/app/room/slideViewer.tsx +++ b/src/app/room/slideViewer.tsx @@ -10,6 +10,7 @@ import { Upload, LogOut, Unlink, + Undo2, } from "lucide-react"; import { createPluginRegistration } from "@embedpdf/core"; import { EmbedPDF } from "@embedpdf/core/react"; @@ -25,6 +26,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useRoom } from "./RoomContext"; +import type { SlideContextSnapshot } from "./RoomContext"; // --------------------------------------------------------------------------- // Props @@ -33,6 +35,8 @@ import { useRoom } from "./RoomContext"; interface SlideViewerProps { isProfessor: boolean; onEndLecture?: () => void; + onSlideContextChange?: (ctx: SlideContextSnapshot) => void; + slideNavTarget?: SlideContextSnapshot | null; } // --------------------------------------------------------------------------- @@ -42,12 +46,23 @@ interface SlideViewerProps { interface SlideUIProps { activeDocumentId: string | null; isProfessor: boolean; + slideSetId: string | null; + slideNavTarget?: SlideContextSnapshot | null; + onSlideContextChange?: (ctx: SlideContextSnapshot) => void; onReplaceSlides?: (file: File) => void; onEndLecture?: () => void; } -function SlideUI({ activeDocumentId, isProfessor, onReplaceSlides, onEndLecture }: SlideUIProps) { - const { socket, sessionId } = useRoom(); +function SlideUI({ + activeDocumentId, + isProfessor, + slideSetId, + slideNavTarget, + onSlideContextChange, + onReplaceSlides, + onEndLecture, +}: SlideUIProps) { + const { socket, sessionId, slideReturnTarget, goBackToPreviousSlide } = useRoom(); const router = useRouter(); const [pageIndex, setPageIndex] = useState(0); @@ -60,6 +75,16 @@ function SlideUI({ activeDocumentId, isProfessor, onReplaceSlides, onEndLecture const activeDocument = docManager?.getActiveDocument(); const pageCount = activeDocument?.pageCount || 0; + // Report the viewer's local page to the room container for question context + useEffect(() => { + if (!onSlideContextChange) return; + if (!slideSetId) { + onSlideContextChange({ slidePageIndex: null, slideSetId: null }); + return; + } + onSlideContextChange({ slidePageIndex: pageIndex, slideSetId }); + }, [pageIndex, slideSetId, onSlideContextChange]); + // ------------------------------------------------------------------------- // Socket — slide sync + live updates // ------------------------------------------------------------------------- @@ -126,16 +151,47 @@ function SlideUI({ activeDocumentId, isProfessor, onReplaceSlides, onEndLecture // Navigation helpers // ------------------------------------------------------------------------- - const navigateTo = (newIndex: number) => { + const navigateToLocal = useCallback( + (newIndex: number, options?: { detachFromProfessor?: boolean }) => { + if (pageCount === 0) return; + const clamped = Math.max(0, Math.min(newIndex, pageCount - 1)); + if (options?.detachFromProfessor && !isProfessor && isSynced) { + setIsSynced(false); + } + setPageIndex(clamped); + setInputValue(String(clamped + 1)); + }, + [pageCount, isProfessor, isSynced] + ); + + const navigateTo = useCallback( + (newIndex: number) => { + if (pageCount === 0) return; + const clamped = Math.max(0, Math.min(newIndex, pageCount - 1)); + navigateToLocal(clamped); + + if (isProfessor && socket) { + socket.emit("slide:change", { sessionId, pageIndex: clamped }); + } + }, + [pageCount, navigateToLocal, isProfessor, socket, sessionId] + ); + + // Question-badge jump: professor broadcasts; students detach from follow mode + useEffect(() => { + if (slideNavTarget?.slidePageIndex == null || !slideNavTarget.slideSetId) return; + if (slideNavTarget.slideSetId !== slideSetId) return; if (pageCount === 0) return; - const clamped = Math.max(0, Math.min(newIndex, pageCount - 1)); - setPageIndex(clamped); - setInputValue(String(clamped + 1)); - if (isProfessor && socket) { - socket.emit("slide:change", { sessionId, pageIndex: clamped }); - } - }; + const targetPage = slideNavTarget.slidePageIndex; + queueMicrotask(() => { + if (isProfessor) { + navigateTo(targetPage); + } else { + navigateToLocal(targetPage, { detachFromProfessor: true }); + } + }); + }, [slideNavTarget, slideSetId, pageCount, navigateToLocal, navigateTo, isProfessor]); const handleInputCommit = (value: string) => { const num = parseInt(value, 10); @@ -232,138 +288,153 @@ function SlideUI({ activeDocumentId, isProfessor, onReplaceSlides, onEndLecture )} {/* Controls bar — always rendered */} -
- {/* Professor: live indicator + nav */} - {isProfessor && ( - <> -
- - Live -
-
- - {viewerCount} -
- {onReplaceSlides && ( - <> +
+
+ {slideReturnTarget?.slidePageIndex != null && ( + <> + +
+ + )} + + {/* Professor: live indicator + nav */} + {isProfessor && ( + <> +
+ + Live +
+
+ + {viewerCount} +
+ {onReplaceSlides && ( + <> + + + + )} + +
+ +
setInputValue(e.target.value)} + onBlur={() => handleInputCommit(inputValue)} + onKeyDown={handleKeyDown} + className="w-10 h-9 px-1 text-center bg-white border border-stone-300 rounded focus-visible:ring-1 focus-visible:ring-stone-400 focus-visible:outline-none" /> - - - )} - -
- -
- setInputValue(e.target.value)} - onBlur={() => handleInputCommit(inputValue)} - onKeyDown={handleKeyDown} - className="w-10 h-9 px-1 text-center bg-white border border-stone-300 rounded focus-visible:ring-1 focus-visible:ring-stone-400 focus-visible:outline-none" - /> - {pageCount > 0 && / {pageCount}} -
- - - )} - - {/* Student: following mode */} - {!isProfessor && isSynced && ( - <> -
- - Following Professor -
- -
- - - )} - - {/* Student: free navigation mode */} - {!isProfessor && !isSynced && ( - <> - -
- setInputValue(e.target.value)} - onBlur={() => handleInputCommit(inputValue)} - onKeyDown={handleKeyDown} - className="w-10 h-9 px-1 text-center bg-white border border-stone-300 rounded focus-visible:ring-1 focus-visible:ring-stone-400 focus-visible:outline-none" - /> - {pageCount > 0 && / {pageCount}} -
- - -
- - - )} + {pageCount > 0 && / {pageCount}} +
+ + + )} + + {/* Student: following mode */} + {!isProfessor && isSynced && ( + <> +
+ + Following Professor +
+ +
+ + + )} + + {/* Student: free navigation mode */} + {!isProfessor && !isSynced && ( + <> + +
+ setInputValue(e.target.value)} + onBlur={() => handleInputCommit(inputValue)} + onKeyDown={handleKeyDown} + className="w-10 h-9 px-1 text-center bg-white border border-stone-300 rounded focus-visible:ring-1 focus-visible:ring-stone-400 focus-visible:outline-none" + /> + {pageCount > 0 && / {pageCount}} +
+ + +
+ + + )} +
); @@ -465,11 +536,17 @@ function UploadZone({ onUpload, isUploading, uploadError, onEndLecture }: Upload // Public component // --------------------------------------------------------------------------- -export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerProps) { +export default function SlideViewer({ + isProfessor, + onEndLecture, + onSlideContextChange, + slideNavTarget, +}: SlideViewerProps) { const { engine, isLoading: engineLoading } = usePdfiumEngine(); const { sessionId, socket } = useRoom(); const [slideUrl, setSlideUrl] = useState(null); + const [activeSlideSetId, setActiveSlideSetId] = useState(null); const [isLoadingSlides, setIsLoadingSlides] = useState(true); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); @@ -481,6 +558,7 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr const loadSlides = useCallback( async (slideSetId?: string) => { if (slideSetId) { + setActiveSlideSetId(slideSetId); setSlideUrl(`/api/sessions/${sessionId}/slides/${slideSetId}/file`); setIsLoadingSlides(false); return; @@ -491,7 +569,10 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr const data = await res.json(); if (data.slideSets?.length > 0) { const latest = data.slideSets[0]; + setActiveSlideSetId(latest.id); setSlideUrl(`/api/sessions/${sessionId}/slides/${latest.id}/file`); + } else { + setActiveSlideSetId(null); } } finally { setIsLoadingSlides(false); @@ -514,6 +595,7 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr if (!socket) return; const onSlidesAvailable = ({ slideSetId }: { slideSetId: string }) => { + setActiveSlideSetId(slideSetId); setSlideUrl(`/api/sessions/${sessionId}/slides/${slideSetId}/file`); }; @@ -523,6 +605,22 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr }; }, [socket, sessionId]); + // Clear slide context when no deck is loaded + useEffect(() => { + if (!slideUrl) { + onSlideContextChange?.({ slidePageIndex: null, slideSetId: null }); + } + }, [slideUrl, onSlideContextChange]); + + // Navigate to a question's slide — switch decks when needed + useEffect(() => { + if (slideNavTarget?.slidePageIndex == null || !slideNavTarget.slideSetId) return; + + if (slideNavTarget.slideSetId !== activeSlideSetId) { + loadSlides(slideNavTarget.slideSetId); + } + }, [slideNavTarget, activeSlideSetId, loadSlides]); + // ------------------------------------------------------------------------- // Upload handler // ------------------------------------------------------------------------- @@ -548,6 +646,7 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr } const newUrl = `/api/sessions/${sessionId}/slides/${data.slideSetId}/file`; + setActiveSlideSetId(data.slideSetId); setSlideUrl(newUrl); if (socket?.connected) { @@ -636,6 +735,9 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr diff --git a/src/lib/questionValidation.ts b/src/lib/questionValidation.ts index 46367d1..1eef58b 100644 --- a/src/lib/questionValidation.ts +++ b/src/lib/questionValidation.ts @@ -36,6 +36,13 @@ export interface SessionValidationResult { }; } +export interface SlideContextValidationResult { + valid: boolean; + error?: string; + slidePageIndex: number | null; + slideSetId: string | null; +} + // --------------------------------------------------------------------------- // Validation Functions // --------------------------------------------------------------------------- @@ -152,3 +159,82 @@ export async function validateSessionForQuestions( return { valid: true, session }; } + +/** + * Validates optional slide context attached to a question. + * Both fields must be provided together or omitted entirely. + * When provided, slidePageIndex must be a non-negative integer within the + * slide set's page count, and slideSetId must belong to the session. + */ +export async function validateQuestionSlideContext( + sessionId: string, + slidePageIndex: unknown, + slideSetId: unknown +): Promise { + const hasPageIndex = slidePageIndex !== undefined && slidePageIndex !== null; + const hasSlideSetId = slideSetId !== undefined && slideSetId !== null; + + if (!hasPageIndex && !hasSlideSetId) { + return { valid: true, slidePageIndex: null, slideSetId: null }; + } + + if (!hasPageIndex || !hasSlideSetId) { + return { + valid: false, + error: "Slide context requires both slidePageIndex and slideSetId.", + slidePageIndex: null, + slideSetId: null, + }; + } + + if ( + typeof slidePageIndex !== "number" || + !Number.isInteger(slidePageIndex) || + slidePageIndex < 0 + ) { + return { + valid: false, + error: "slidePageIndex must be a non-negative integer.", + slidePageIndex: null, + slideSetId: null, + }; + } + + if (typeof slideSetId !== "string" || slideSetId.trim() === "") { + return { + valid: false, + error: "slideSetId must be a non-empty string.", + slidePageIndex: null, + slideSetId: null, + }; + } + + const slideSet = await prisma.slideSet.findFirst({ + where: { id: slideSetId, sessionId }, + select: { id: true, pageCount: true }, + }); + + if (!slideSet) { + return { + valid: false, + error: "slideSetId does not belong to this session.", + slidePageIndex: null, + slideSetId: null, + }; + } + + if (slidePageIndex >= slideSet.pageCount) { + return { + valid: false, + error: `slidePageIndex must be less than ${slideSet.pageCount}.`, + slidePageIndex: null, + slideSetId: null, + }; + } + + return { + valid: true, + slidePageIndex, + slideSetId, + }; +} diff --git a/src/server.ts b/src/server.ts index 6ab757c..da22662 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,9 @@ -import "dotenv/config"; // must be first — loads .env before any other module runs +import dotenv from "dotenv"; + +// Load .env, then let .env.local override (same as prisma.config.ts). +// Required for `pnpm dev` outside Docker: hosts must be localhost, not postgres/redis. +dotenv.config(); +dotenv.config({ path: ".env.local", override: true }); import { createServer, type IncomingMessage } from "node:http"; import next from "next"; diff --git a/src/socket/handlers/questionHandlers.ts b/src/socket/handlers/questionHandlers.ts index 8f9397b..37ee86a 100644 --- a/src/socket/handlers/questionHandlers.ts +++ b/src/socket/handlers/questionHandlers.ts @@ -7,6 +7,7 @@ import { checkUpvoteRateLimit, checkResolveRateLimit, validateSessionForQuestions, + validateQuestionSlideContext, } from "@/lib/questionValidation"; // --------------------------------------------------------------------------- @@ -18,6 +19,8 @@ interface QuestionCreatePayload { sessionId: string; visibility?: "PUBLIC" | "INSTRUCTOR_ONLY"; isAnonymous?: boolean; + slidePageIndex?: number; + slideSetId?: string; } interface QuestionBroadcastPayload { @@ -29,6 +32,8 @@ interface QuestionBroadcastPayload { authorId?: string | null; authorName?: string | null; authorUtorid?: string | null; + slidePageIndex?: number | null; + slideSetId?: string | null; } interface QuestionUpvotePayload { @@ -129,6 +134,21 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { return; } + // 5b. Slide context validation — invalid context is dropped; question still saves + const slideValidation = await validateQuestionSlideContext( + payload.sessionId, + payload.slidePageIndex, + payload.slideSetId + ); + if ( + !slideValidation.valid && + (payload.slidePageIndex != null || payload.slideSetId != null) + ) { + console.log("[QuestionHandler] Invalid slide context dropped:", slideValidation.error); + } + const slidePageIndex = slideValidation.valid ? slideValidation.slidePageIndex : null; + const slideSetId = slideValidation.valid ? slideValidation.slideSetId : null; + // 5a. Enrollment check — any non-PROFESSOR must be enrolled in the session's course const sessionForEnrollment = await prisma.session.findUnique({ where: { id: payload.sessionId }, @@ -157,6 +177,8 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { content: payload.content.trim(), visibility: payload.visibility ?? "PUBLIC", isAnonymous: payload.isAnonymous ?? false, + slidePageIndex, + slideSetId, }, include: { author: { select: { id: true, name: true, utorid: true } }, @@ -170,6 +192,8 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { visibility: question.visibility, isAnonymous: question.isAnonymous, createdAt: question.createdAt, + slidePageIndex: question.slidePageIndex, + slideSetId: question.slideSetId, ...(question.isAnonymous ? {} : { diff --git a/src/socket/types.ts b/src/socket/types.ts index a76b815..3cadcc3 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -10,6 +10,8 @@ export interface QuestionCreatePayload { sessionId: string; visibility?: "PUBLIC" | "INSTRUCTOR_ONLY"; isAnonymous?: boolean; + slidePageIndex?: number; + slideSetId?: string; } export interface QuestionCreatedPayload { @@ -21,6 +23,8 @@ export interface QuestionCreatedPayload { authorId?: string | null; authorName?: string | null; authorUtorid?: string | null; + slidePageIndex?: number | null; + slideSetId?: string | null; } export interface AnswerModeChangePayload { diff --git a/src/utils/types.ts b/src/utils/types.ts index a0366e1..cc3d55b 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -26,6 +26,8 @@ export interface Question extends BasePost { replies: Comment[]; isResolved: boolean; visibility?: "PUBLIC" | "INSTRUCTOR_ONLY"; + slidePageIndex?: number | null; + slideSetId?: string | null; } export interface Comment extends BasePost {