diff --git a/.env.sample b/.env.sample index 3784206..4d8ed5a 100644 --- a/.env.sample +++ b/.env.sample @@ -1,18 +1,18 @@ # Config -PUBLIC_SERVER_URL=http://localhost:4000 PUBLIC_MOCK_DATA=true -SERVER_PORT=4000 # optional, server port (alias: PORT, PUBLIC_SERVER_URL.port) (defaults to 4000 if none are specified) -# Secrets -PUBLIC_X_ANON_KEY=... -X_API_KEY=... +# 開発中 -> Vite: BASE_URL.port, Elysia: EXTERNAL_SERVER_PORT +# プロダクション -> Vite: - , Elysia: PORT ?? BASE_URL.port +BASE_URL=http://localhost:3000 +EXTERNAL_SERVER_PORT=4000 +NODE_ENV=development + +# Database DATABASE_URL=file:../../local.db +# Better Auth BETTER_AUTH_SECRET=... -BETTER_AUTH_URL=http://localhost:4000 GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET= - -PUBLIC_WEB_URL=http://localhost:3000 diff --git a/.github/workflows/static-checks.yaml b/.github/workflows/static-checks.yaml index a4dfbd6..b0573a5 100644 --- a/.github/workflows/static-checks.yaml +++ b/.github/workflows/static-checks.yaml @@ -6,6 +6,10 @@ on: - main pull_request: +env: + BASE_URL: http://localhost:3000 + EXTERNAL_SERVER_PORT: 4000 + jobs: build: name: Build @@ -15,6 +19,7 @@ jobs: - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile - run: bun run build + biome: name: Biome Checks runs-on: ubuntu-latest diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..deee0ff --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,18 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + +jobs: + bun: + name: Bun Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun test \ No newline at end of file diff --git a/TODO.md b/TODO.md index 11d654e..06ee05d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,3 @@ # TODOs -- setup elysia server and RPC - implement build script diff --git a/bun.lock b/bun.lock index 6234008..871f3f4 100644 --- a/bun.lock +++ b/bun.lock @@ -3,6 +3,10 @@ "workspaces": { "": { "name": "syllabus", + "dependencies": { + "@elysiajs/eden": "^1.3.2", + "elysia": "^1.3.6", + }, "devDependencies": { "@biomejs/biome": "^2.1.1", "concurrently": "^9.2.0", @@ -29,7 +33,8 @@ "dependencies": { "@elysiajs/cors": "^1.3.3", "@libsql/client": "^0.15.10", - "@sinclair/typebox": "^0.34.37", + "@packages/class_data": "workspace:*", + "@sinclair/typebox": "^0.34.38", "better-auth": "^1.3.1", "drizzle-orm": "^0.44.3", "elysia": "^1.3.5", @@ -43,20 +48,30 @@ "typescript": "^5.8.3", }, }, + "packages/tests": { + "name": "tests", + "dependencies": { + "@packages/class_data": "workspace:*", + "@packages/models": "workspace:*", + "@sinclair/typebox": "^0.34.38", + }, + }, "packages/web": { "name": "@packages/web", "version": "0.1.0", "dependencies": { "@elysiajs/eden": "^1.3.2", "@headlessui/react": "^2.2.4", - "@packages/class_data": "workspace:*", + "@packages/models": "workspace:models", "@packages/server": "workspace:*", + "@sinclair/typebox": "^0.34.38", "@tanstack/react-query": "^5.83.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-icons": "^5.5.0", "react-router-dom": "^7.1.1", + "svelte": "^5.37.0", }, "devDependencies": { "@storybook/addon-essentials": "^8.6.14", @@ -417,6 +432,8 @@ "@storybook/theming": ["@storybook/theming@8.6.14", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-r4y+LsiB37V5hzpQo+BM10PaCsp7YlZ0YcZzQP1OCkPlYXmUAFy2VvDKaFRpD8IeNPKug2u4iFm/laDEbs03dg=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], + "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], @@ -513,7 +530,7 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], @@ -523,6 +540,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "better-auth": ["better-auth@1.3.3", "", { "dependencies": { "@better-auth/utils": "0.2.5", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.12", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.28.1", "nanostores": "^0.11.3", "zod": "^4.0.5" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-q1aD2nNpGfEI2ckYu+pBjN+23CIRctOpmREkWyJDJdoYW1q9EPs1Xdb+KhFztg2rMmsoUN8I9Xm5mUWMxiWuLw=="], @@ -609,7 +628,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.190", "", {}, "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw=="], - "elysia": ["elysia@1.3.5", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-XVIKXlKFwUT7Sta8GY+wO5reD9I0rqAEtaz1Z71UgJb61csYt8Q3W9al8rtL5RgumuRR8e3DNdzlUN9GkC4KDw=="], + "elysia": ["elysia@1.3.6", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-NPjbt42KlLzf98JN/3Uvzg3oVFCzEle9gJnRtwUxYGQd61Bm7sE1h8FMqQVdXRCoxmz1T+jhhoB+p1YjDdwEzQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -627,8 +646,12 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -701,6 +724,8 @@ "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], @@ -751,6 +776,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], @@ -913,6 +940,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svelte": ["svelte@5.37.0", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-BAHgWdKncZ4F1DVBrkKAvelx2Nv3mR032ca8/yj9Gxf5s9zzK1uGXiZTjCFDvmO2e9KQfcR2lEkVjw+ZxExJow=="], + "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], @@ -921,6 +950,8 @@ "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "tests": ["tests@workspace:packages/tests"], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], @@ -985,6 +1016,8 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="], + "zod": ["zod@4.0.8", "", {}, "sha512-+MSh9cZU9r3QKlHqrgHMTSr3QwMGv4PLfR0M4N/sYWV5/x67HgXEhIGObdBkpnX8G78pTgWnIrBL2lZcNJOtfg=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -1013,6 +1046,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], diff --git a/docs/developer_readme.md b/docs/developer_readme.md index e6f03a3..ea14347 100644 --- a/docs/developer_readme.md +++ b/docs/developer_readme.md @@ -16,11 +16,11 @@ bun db push ```bash # 開発モードを実行するには、以下のコマンドを実行してください。 -# localhost:5173 に Vite サーバーが立ち上がるので、そこで確認してください。 +# $BASE_URL に Vite サーバーが立ち上がるので、そこで確認してください。 bun dev # Storybookの使用 -# localhost:6006にStorybookが立ち上がるので、そこでUIを確認してください。 +# localhost:6006 にStorybookが立ち上がるので、そこでUIを確認してください。 bun run storybook ``` @@ -36,28 +36,26 @@ bun check bun fix ``` -## モックモード +## サーバー構成 -モックモードを実行するには、以下のコマンドを実行してください。 +### 1. 開発中 -```bash -bun dev:mock -``` +- Vite 開発サーバー (localhost:3000) -> すべてのリクエストはこのサーバーがプロキシ (ログインのリダイレクトを除く) +- Elysia サーバー (localhost:4000) -> /api 以下のリクエストをプロキシされて受け取る -このコマンドを実行すると、モックデータを使用してアプリケーションが実行されます。 +### 2. プロダクション -## 関数やクラスの説明 +- Elysia サーバー (localhost:${PORT}) -> /api 以外は public/index.html を返す -### 1. Userのデータを扱う場合 (src/app/utils/user.ts) +## モックモード -Userのデータは`User`クラスを使用して扱います。Userのデータは以下の場合があります。 +モックモードを実行するには、以下のコマンドを実行してください。 -- `bun dev:mock`を実行した場合 - - Userはモックのデータが使用されます。 -- `bun dev`を実行した場合 - - UserはlocalStorageに保存されたデータが使用されます。 +```bash +PUBLIC_MOCK_DATA=true bun dev +``` -ただ、まだユーザを登録する機能がないので、mockでユーザを作成する必要があります。 +このコマンドを実行すると、モックデータを使用してアプリケーションが実行されます。 ## 推奨 VS Code 設定 diff --git a/package.json b/package.json index 940ce6b..93ed83c 100644 --- a/package.json +++ b/package.json @@ -17,5 +17,9 @@ "devDependencies": { "@biomejs/biome": "^2.1.1", "concurrently": "^9.2.0" + }, + "dependencies": { + "@elysiajs/eden": "^1.3.2", + "elysia": "^1.3.6" } } diff --git a/packages/models/atoms.ts b/packages/models/atoms.ts new file mode 100644 index 0000000..6c2a573 --- /dev/null +++ b/packages/models/atoms.ts @@ -0,0 +1,89 @@ +import { t } from "elysia"; + +// Stream って何? 教えて有識者 +// Course.importance に使われているよう? +export type Stream = typeof Stream.static; +export const Stream = t.UnionEnum(["s1", "s2", "s3", "l1", "l2", "l3"]); + +/** + * 授業コード + * 例: 30003 + */ +export type CourseCode = typeof CourseCode.static; +export const CourseCode = t.RegExp("^\\d{5}$"); + +/** + * 共通科目コード + * 例: + * - XAB-CD1001L2 + * - CAS-FC1871L1 + * - CAS-GC1L37S4 + * - CASPG1F40L3 // 絶対入力ミスだが、データにあるので対応しなければならない + * 仕様: https://www.u-tokyo.ac.jp/ja/students/classes/course-numbering.html + * 本当は CommonSubjectCode になるはずだが、公式が勝手に CommonCourseCode と読んでいる + */ +export type CommonCourseCode = typeof CommonCourseCode.static; +export const CommonCourseCode = t.RegExp( + ` + ^[CFG] ${/* [1] 課程コード */ ""} + (?:LA|ME|EN|LE|SC|AG|EC|AS|ED|PH|GL) ${/* [2] 開講学部・研究科(教育部)コード */ ""} + -? + [A-Z]{2} ${/* [3] 開講学科・専攻等コード */ ""} + [1-7] ${/* [4] レベルコード */ ""} + [0-9a-zA-Z]{3} ${/* [5] 整理番号 */ ""} + [LSEPTZ] ${/* [6] 授業形態コード */ ""} + [123459] ${/* [7] 使用言語コード */ ""} + $`.replaceAll(/\s/g, ""), +); + +/** + * 曜日。 + */ +export type Day = typeof Day.static; +export const Day = t.UnionEnum(["mon", "tue", "wed", "thu", "fri", "sat"]); + +export type Period = typeof Period.static; +export const Period = t.UnionEnum([1, 2, 3, 4, 5, 6]); + +/** + * 曜限。 + */ +export type DayPeriod = typeof DayPeriod.static; +export const DayPeriod = t.Object({ + day: Day, + period: Period, +}); + +/** + * セメスターまたはターム。 + */ +export type Semester = typeof Semester.static; +export const Semester = t.UnionEnum(["S", "S1", "S2", "A", "A1", "A2"]); + +// 使われていない。 +/** + * 評価方法。 + */ +export type Evaluation = typeof Evaluation.static; +export const Evaluation = t.UnionEnum(["試験", "レポート", "出席", "平常"]); + +/** + * 単位の種類。 + */ +export type ClassType = typeof ClassType.static; +export const ClassType = t.UnionEnum(["基礎", "要求", "主題", "総合", "展開"]); + +export type ClassSeries = typeof ClassSeries.static; +export const ClassSeries = t.UnionEnum([ + "基礎", + "要求", + "主題", + "展開", + "総合L", + "総合A", + "総合B", + "総合C", + "総合D", + "総合E", + "総合F", +]); diff --git a/packages/models/mappings.ts b/packages/models/mappings.ts new file mode 100644 index 0000000..3054b25 --- /dev/null +++ b/packages/models/mappings.ts @@ -0,0 +1,10 @@ +import type { Day } from "./atoms.ts"; + +export const dayMapping: { [key in Day]: string } = { + mon: "月", + tue: "火", + wed: "水", + thu: "木", + fri: "金", + sat: "土", +}; diff --git a/packages/models/models.ts b/packages/models/models.ts index 7c2cd27..a3865ba 100644 --- a/packages/models/models.ts +++ b/packages/models/models.ts @@ -1,93 +1,98 @@ +export * from "./atoms.ts"; +export * from "./mappings.ts"; + import { t } from "elysia"; -// TODO: Elysia のスキーマにする -// サンプル変換: +import { + ClassSeries, + ClassType, + CommonCourseCode, + CourseCode, + DayPeriod, + Semester, + Stream, +} from "./atoms.ts"; -export type Stream = typeof Stream.static; -export const Stream = t.Enum({ - s1: "s1", - s2: "s2", - s3: "s3", - l1: "l1", - l2: "l2", - l3: "l3", +export type UserData = typeof UserData.static; +export const UserData = t.Object({ + stream: Stream, + grade: t.Number(), + classNumber: t.Number(), + courses: t.Array(CourseCode), }); -export type User = { - stream: Stream | undefined; - grade: number | undefined; - classNumber: number | undefined; -}; +/** + * 分かりにくいフィールドがたくさんあるので、 + * それぞれの意味の説明を追加する + */ +export type Course = typeof Course.static; +export const Course = t.Object({ + /// 基本情報 + code: CourseCode, + ccCode: CommonCourseCode, + /** 講義名 (例: 大規模計算) */ + titleJp: t.String(), + /** 講義名 (例: Large-Scale Computing) */ + titleEn: t.String(), + lecturer: t.String(), + lecturerEn: t.String(), -export type ClassDataType = { - code: string; - type: string; - category: string; - semester: string; - dayPeriod: DayPeriod[] | "集中"; - classroom: string; - titleJp: string; - lecturer: string; - titleEn: string; - lecturerEn: string; - ccCode: string; - credits: number; - detail: string; - schedule: string; - methods: string; - evaluation: string; - notes: string; - class: string; - guidance: string; - guidanceDate: string; - guidancePeriod: string; - time: number; - timeCompensation: string; - targetClass: string[][]; - importance: string[][]; - shortenedCategory: string; - shortenedEvaluation: string; - shortenedClassroom: string; -}; + /// 講義の内容に関する情報 + /** 授業詳細 */ + detail: t.String(), + /** 授業スケジュール (内容的な方) */ + schedule: t.String(), + notes: t.String(), // 備考 -export type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat"; + /// 授業の開催に関する情報 + semester: Semester, + /** 開催教室 (例: 駒場5号館 523教室) */ + classroom: t.String(), + /** 開催教室の短縮表示 (例: 523) */ + shortenedClassroom: t.String(), + /** + * 授業の方法 + * 例: 講義形式であるが, 担当教員によっては適宜小テストやレポートを課すことがある. 成績評価は主として期末試験によって行なう. + */ + methods: t.String(), + /** 授業の長さ (分) (例: 90) */ + time: t.Number(), + dayPeriod: t.Union([t.Array(DayPeriod), t.Literal("集中")]), -export type DayPeriod = { - day: Day; - period: 1 | 2 | 3 | 4 | 5 | 6; -}; + /// 講義のカテゴライジングに関する情報 + /** 授業の種別 (例: 基礎) */ + type: ClassType, + /** 授業のカテゴリー (例: 数理科学) */ + category: t.String(), + /** + * 科目区分+系列 (例: 基礎、総合A) + * 授業カテゴリの短縮表示**じゃない**!!!! + */ + shortenedCategory: ClassSeries, -export const dayMapping: { [key in Day]: string } = { - mon: "月", - tue: "火", - wed: "水", - thu: "木", - fri: "金", - sat: "土", -}; + /// 単位に関する情報 + /** 単位数 (例: 2) */ + credits: t.Number(), + /** 評価方法 (例: 出席状況、提出物などの状況、研究の達成度などをもとに評価します。) */ + evaluation: t.String(), + shortenedEvaluation: t.String(), -/** - * セメスターを表現する型 - */ -export type Semester = "S" | "S1" | "S2" | "A" | "A1" | "A2"; + /// ガイダンス情報 + guidance: t.String(), + guidanceDate: t.String(), + guidancePeriod: t.String(), -/** - * 評価方法を表現する型 - */ -export type Evaluation = "試験" | "レポート" | "出席" | "平常"; + /// なんなのかよくわからない。わかったら、対応する場所に移動して + timeCompensation: t.String(), + targetClass: t.Array(t.Array(t.String())), + class: t.String(), + importance: t.Array(t.Array(t.String())), +}); +export type CourseList = typeof CourseList.static; +export const CourseList = t.Array(Course); /** - * セメスターを表現する型 + * code -> Course */ -export type ClassType = - | "基礎" - | "要求" - | "主題" - | "展開" - | "L" - | "A" - | "B" - | "C" - | "D" - | "E" - | "F"; +export type CourseCollection = typeof CourseCollection.static; +export const CourseCollection = t.Record(t.String(), Course); diff --git a/packages/models/package.json b/packages/models/package.json index 9858896..51b9284 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -3,7 +3,8 @@ "type": "module", "private": true, "exports": { - ".": "./models.ts" + ".": "./models.ts", + "./transformer": "./transformer/transformer.ts" }, "devDependencies": { "@types/bun": "^1.2.18" diff --git a/packages/models/transformer/transformer.ts b/packages/models/transformer/transformer.ts new file mode 100644 index 0000000..19b0593 --- /dev/null +++ b/packages/models/transformer/transformer.ts @@ -0,0 +1,9 @@ +import type { CourseCollection, CourseList } from "@packages/models"; + +export function courseListToCourseCollection(courseList: CourseList) { + const collection: CourseCollection = {}; + for (const course of courseList) { + collection[course.code] = course; + } + return collection; +} diff --git a/packages/server/app.ts b/packages/server/app.ts index 0f1b4bf..c46d6f0 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -1,11 +1,16 @@ import { cors } from "@elysiajs/cors"; import { Elysia } from "elysia"; -import { betterAuth } from "./lib/auth.ts"; +import { auth, betterAuth } from "./lib/auth.ts"; +import coursesRouter from "./router/courses.ts"; +import usersRouter from "./router/users.ts"; export const app = new Elysia({ prefix: "/api", }) + .mount(auth.handler) + .use(cors()) .use(betterAuth) - .use(cors()); + .use(coursesRouter) + .use(usersRouter); export type App = typeof app; diff --git a/packages/server/db/client.ts b/packages/server/db/client.ts new file mode 100644 index 0000000..df4328e --- /dev/null +++ b/packages/server/db/client.ts @@ -0,0 +1,8 @@ +import { drizzle } from "drizzle-orm/libsql"; +import { env } from "../lib/env.ts"; +import * as schema from "./schema.ts"; + +export const db = drizzle({ + connection: { url: env.DATABASE_URL }, + schema, +}); diff --git a/packages/server/db/index.ts b/packages/server/db/index.ts deleted file mode 100644 index 750967d..0000000 --- a/packages/server/db/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { drizzle } from "drizzle-orm/libsql"; - -export const db = drizzle({ - connection: { url: process.env.DATABASE_URL ?? "file:local.db" }, -}); diff --git a/packages/server/db/schema.ts b/packages/server/db/schema.ts index 4c1e2d1..b20f97b 100644 --- a/packages/server/db/schema.ts +++ b/packages/server/db/schema.ts @@ -1,6 +1,6 @@ +import { relations } from "drizzle-orm"; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -// auth export const user = sqliteTable("users", { id: text("id").primaryKey(), name: text("name").notNull(), @@ -16,7 +16,24 @@ export const user = sqliteTable("users", { .$defaultFn(() => /* @__PURE__ */ new Date()) .notNull(), }); +export const userData = sqliteTable("user_data", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + stream: text("stream"), + grade: integer("grade"), + classNumber: integer("class_number"), + courses: text("courses", { mode: "json" }).notNull(), +}); +export const userRelations = relations(user, ({ one }) => ({ + data: one(userData, { + fields: [user.id], + references: [userData.userId], + }), +})); +// auth-related tables export const session = sqliteTable("sessions", { id: text("id").primaryKey(), expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), diff --git a/packages/server/lib/auth.ts b/packages/server/lib/auth.ts index d6bcbe8..575b7bb 100644 --- a/packages/server/lib/auth.ts +++ b/packages/server/lib/auth.ts @@ -1,11 +1,12 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import Elysia from "elysia"; -import { db } from "../db/index.ts"; +import { db } from "../db/client.ts"; import * as schema from "../db/schema.ts"; import { env } from "../lib/env.ts"; export const auth = betterAuth({ + baseURL: env.BASE_URL, database: drizzleAdapter(db, { provider: "sqlite", schema: schema, @@ -16,24 +17,25 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET, }, }, - trustedOrigins: [env.PUBLIC_WEB_URL], + trustedOrigins: [env.BASE_URL], + advanced: { + cookiePrefix: "x_utcode_syllabus_", + }, }); -const betterAuthMacro = new Elysia({ name: "better-auth" }) - .mount(auth.handler) - .macro({ - auth: { - async resolve({ status, request: { headers } }) { - const session = await auth.api.getSession({ - headers, - }); - if (!session) return status(401); +const betterAuthMacro = new Elysia({ name: "better-auth" }).macro({ + auth: { + async resolve({ status, request: { headers } }) { + const session = await auth.api.getSession({ + headers, + }); + if (!session) return status(401); - return { - user: session.user, - session: session.session, - }; - }, + return { + user: session.user, + session: session.session, + }; }, - }); + }, +}); export { betterAuthMacro as betterAuth }; diff --git a/packages/server/lib/env.ts b/packages/server/lib/env.ts index e4f3181..613f212 100644 --- a/packages/server/lib/env.ts +++ b/packages/server/lib/env.ts @@ -1,14 +1,20 @@ import * as e from "./utils/environment.ts"; +import { panic } from "./utils/panic.ts"; export const env = { DATABASE_URL: e.string("DATABASE_URL"), - PUBLIC_WEB_URL: e.string("PUBLIC_WEB_URL"), GOOGLE_CLIENT_ID: e.string("GOOGLE_CLIENT_ID"), GOOGLE_CLIENT_SECRET: e.string("GOOGLE_CLIENT_SECRET"), - SERVER_PORT: e.string_optional("SERVER_PORT"), - PORT: e.string_optional("PORT"), - PUBLIC_SERVER_URL: e.string_optional("PUBLIC_SERVER_URL"), + LISTEN_PORT: getListenPort() ?? panic("[env] Port to listen not found"), + BASE_URL: e.string("BASE_URL"), PUBLIC_MOCK_DATA: e.boolean("PUBLIC_MOCK_DATA"), }; + +function getListenPort() { + if (e.string("NODE_ENV") === "development") { + return e.string_optional("EXTERNAL_SERVER_PORT"); + } + return e.string_optional("PORT") ?? new URL(e.string("BASE_URL")).port; +} diff --git a/packages/server/lib/utils/dateconv.test.ts b/packages/server/lib/utils/dateconv.test.ts new file mode 100644 index 0000000..cf81401 --- /dev/null +++ b/packages/server/lib/utils/dateconv.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "bun:test"; +import { toHttpDate, toUnixTime } from "./dateconv.ts"; + +describe("toHttpDate", () => { + it("should format a date as an HTTP date string", () => { + const date = new Date(Date.UTC(2006, 0, 2, 15, 4, 5)); + expect(toHttpDate(date)).toBe("Mon, 02 Jan 2006 15:04:05 GMT"); + }); +}); + +describe("toUnixTime", () => { + it("should convert a date to a Unix timestamp", () => { + const date = new Date(Date.UTC(1971, 0, 1, 0, 0, 0)); + expect(toUnixTime(date)).toBe(31536000); + }); +}); diff --git a/packages/server/lib/utils/dateconv.ts b/packages/server/lib/utils/dateconv.ts new file mode 100644 index 0000000..c067dd6 --- /dev/null +++ b/packages/server/lib/utils/dateconv.ts @@ -0,0 +1,7 @@ +export function toUnixTime(date: Date) { + return Math.floor(date.getTime() / 1000); +} + +export function toHttpDate(date: Date) { + return date.toUTCString(); +} diff --git a/packages/server/lib/utils/environment.ts b/packages/server/lib/utils/environment.ts index 3c8c636..d31e1a4 100644 --- a/packages/server/lib/utils/environment.ts +++ b/packages/server/lib/utils/environment.ts @@ -19,3 +19,13 @@ export function boolean(name: string, fallback?: boolean): boolean { if (fallback !== undefined) return fallback; panic(`Environment variable ${name} not found`); } + +export function array(name: string, splitter: string): string[] { + return string(name).split(splitter); +} +export function array_optional( + name: string, + splitter: string, +): string[] | undefined { + return string_optional(name)?.split(splitter); +} diff --git a/packages/server/package.json b/packages/server/package.json index 9b156d9..651b8b4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -4,7 +4,8 @@ "type": "module", "private": true, "exports": { - ".": "./app.ts" + ".": "./app.ts", + "./types": "./types.ts" }, "scripts": { "dev": "bun --env-file=../../.env run --watch ./serve.ts" @@ -20,7 +21,8 @@ "dependencies": { "@elysiajs/cors": "^1.3.3", "@libsql/client": "^0.15.10", - "@sinclair/typebox": "^0.34.37", + "@packages/class_data": "workspace:*", + "@sinclair/typebox": "^0.34.38", "better-auth": "^1.3.1", "drizzle-orm": "^0.44.3", "elysia": "^1.3.5" diff --git a/packages/server/router/courses.ts b/packages/server/router/courses.ts new file mode 100644 index 0000000..07985c0 --- /dev/null +++ b/packages/server/router/courses.ts @@ -0,0 +1,35 @@ +import courses2024A from "@packages/class_data/data/new/2024A.json"; +import { CourseList } from "@packages/models"; +import { Value } from "@sinclair/typebox/value"; +import Elysia, { status, t } from "elysia"; +import { toUnixTime } from "../lib/utils/dateconv.ts"; + +const semesterCoursesMap = new Map([ + ["2024A", Value.Parse(CourseList, courses2024A)], +]); + +const router = new Elysia({ + prefix: "/courses", +}).get( + "/", + ({ query, set }) => { + const courses = semesterCoursesMap.get(query.name); + if (!courses) { + return status(404, `Entry not found: ${query.name}`); + } + + // this never expires? + const validUntil = new Date(); + validUntil.setFullYear(validUntil.getFullYear() + 1); + + set.headers["cache-control"] = `public, expires=${toUnixTime(validUntil)}`; + return courses; + }, + { + query: t.Object({ + name: t.String(), + }), + }, +); + +export default router; diff --git a/packages/server/router/users.ts b/packages/server/router/users.ts new file mode 100644 index 0000000..7c154b7 --- /dev/null +++ b/packages/server/router/users.ts @@ -0,0 +1,66 @@ +import { CourseCode, UserData } from "@packages/models"; +import { Value } from "@sinclair/typebox/value"; +import { eq } from "drizzle-orm"; +import Elysia, { status, t } from "elysia"; +import { db } from "../db/client.ts"; +import { userData } from "../db/schema.ts"; +import { betterAuth } from "../lib/auth.ts"; +import type { SelectUser } from "../types.ts"; + +const router = new Elysia({ + prefix: "/users", +}) + .use(betterAuth) + .get( + "/me", + async ({ user: { id: currentUserId } }) => { + const fullUser = await db.query.user.findFirst({ + where: (user) => eq(user.id, currentUserId), + with: { + data: true, + }, + }); + if (!fullUser) return status(404, "User not found"); + + try { + if (fullUser.data) { + Value.Assert(t.Array(CourseCode), fullUser.data.courses); + } + return fullUser; + } catch { + // it's not recoverable - reset courses + console.error("Failed to parse courses:", fullUser.data?.courses); + await db + .update(userData) + .set({ + courses: [], + }) + .where(eq(userData.userId, currentUserId)); + return status(500, "Internal server error: Failed to parse courses"); + } + }, + { + auth: true, + }, + ) + .put( + "/me/data", + async ({ user: { id: currentUserId }, body }) => { + await db + .update(userData) + .set({ + stream: body.stream, + grade: body.grade, + classNumber: body.classNumber, + courses: body.courses, + }) + .where(eq(userData.userId, currentUserId)); + }, + { + auth: true, + body: UserData, + }, + ); +// TODO: 画像を更新する + +export default router; diff --git a/packages/server/serve.ts b/packages/server/serve.ts index 17b789a..b69246f 100644 --- a/packages/server/serve.ts +++ b/packages/server/serve.ts @@ -1,15 +1,6 @@ import { app } from "./app.ts"; import { env } from "./lib/env.ts"; -const port = - env.SERVER_PORT ?? env.PORT ?? parsePort(env.PUBLIC_SERVER_URL) ?? "4000"; - -app.listen(port, () => - console.log(`Server started at http://localhost:${port}`), +app.listen(env.LISTEN_PORT, () => + console.log(`Server started at http://localhost:${env.LISTEN_PORT}`), ); - -function parsePort(urlstr: string | undefined) { - if (!urlstr) return undefined; - const url = new URL(urlstr); - return url.port; -} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 9be82aa..a0b14ec 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -8,6 +8,8 @@ "moduleDetection": "force", "allowJs": true, + "strict": true, + // Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, diff --git a/packages/server/types.ts b/packages/server/types.ts new file mode 100644 index 0000000..9272871 --- /dev/null +++ b/packages/server/types.ts @@ -0,0 +1,9 @@ +import type { CourseCode } from "@packages/models"; +import type { InferSelectModel } from "drizzle-orm"; +import type { user, userData } from "./db/schema.ts"; + +export type SelectUser = InferSelectModel & { + data: InferSelectModel & { + courses: CourseCode[]; // you must validate it yourself + }; +}; diff --git a/packages/tests/.gitignore b/packages/tests/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/tests/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/tests/class_data_parsing.test.ts b/packages/tests/class_data_parsing.test.ts new file mode 100644 index 0000000..1dd62b0 --- /dev/null +++ b/packages/tests/class_data_parsing.test.ts @@ -0,0 +1,15 @@ +import { test } from "bun:test"; +import classData2024A from "@packages/class_data/data/new/2024A.json"; +import { Course } from "@packages/models"; +import { Type } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; + +const CourseList = Type.Array(Course); +test("授業リストは Course の配列である", () => { + try { + Value.Parse(CourseList, classData2024A); + } catch (e) { + console.error(e); + throw e; + } +}); diff --git a/packages/tests/package.json b/packages/tests/package.json new file mode 100644 index 0000000..fee44df --- /dev/null +++ b/packages/tests/package.json @@ -0,0 +1,10 @@ +{ + "name": "tests", + "type": "module", + "private": true, + "dependencies": { + "@packages/class_data": "workspace:*", + "@packages/models": "workspace:*", + "@sinclair/typebox": "^0.34.38" + } +} diff --git a/packages/tests/tsconfig.json b/packages/tests/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/packages/tests/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/packages/web/.storybook/preview.tsx b/packages/web/.storybook/preview.tsx index 1a3354e..8b5d855 100644 --- a/packages/web/.storybook/preview.tsx +++ b/packages/web/.storybook/preview.tsx @@ -1,6 +1,6 @@ import type { Preview } from "@storybook/react"; import "../src/app.css"; -import { ThemeContext, useThemeProvider } from "@/services/theme/index.ts"; +import { ThemeContext, useThemeService } from "@/services/theme/index.ts"; const preview: Preview = { parameters: { @@ -17,7 +17,7 @@ export default preview; export const decorators = [ (Story: () => React.ReactNode) => { - const provider = useThemeProvider(); + const provider = useThemeService(); return ( diff --git a/packages/web/package.json b/packages/web/package.json index 50cd152..5732078 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", - "build": "tsc && vite build", + "dev": "bun run --env-file=../../.env vite", + "build": "tsc && bun run --env-file=../../.env vite build", "preview": "vite preview", "check": "tsc --noEmit", "storybook": "storybook dev -p 6006", @@ -14,14 +14,16 @@ "dependencies": { "@elysiajs/eden": "^1.3.2", "@headlessui/react": "^2.2.4", - "@packages/class_data": "workspace:*", + "@packages/models": "workspace:*", "@packages/server": "workspace:*", + "@sinclair/typebox": "^0.34.38", "@tanstack/react-query": "^5.83.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-icons": "^5.5.0", - "react-router-dom": "^7.1.1" + "react-router-dom": "^7.1.1", + "svelte": "^5.37.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.6.14", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 4a80a8a..cf194d6 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,79 +1,45 @@ -import type { User } from "@packages/models"; import { QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { - UserContext, - type UserContextValue, -} from "@/services/user/UserContext.ts"; -import { UserService } from "@/services/user/user.ts"; import Footer from "./components/Footer/index.tsx"; import Header from "./components/Header/index.tsx"; import { queryClient } from "./lib/tanstack/client.ts"; -import AboutUs from "./pages/AboutUs.tsx"; -import Disclaimer from "./pages/Disclaimer.tsx"; -import Home from "./pages/Home.tsx"; -import HowToUse from "./pages/how-to-use/page.tsx"; -import NotFound from "./pages/NotFound.tsx"; -import Notion from "./pages/Notion.tsx"; import Profile from "./pages/Profile.tsx"; +import Setup from "./pages/Setup.tsx"; import SignIn from "./pages/SignIn.tsx"; -import { ThemeContext, useThemeProvider } from "./services/theme/index.ts"; +import AboutUs from "./pages/static/AboutUs.tsx"; +import Disclaimer from "./pages/static/Disclaimer.tsx"; +import HowToUse from "./pages/static/how-to-use/page.tsx"; +import Landing from "./pages/static/LP.tsx"; +import NotFound from "./pages/static/NotFound.tsx"; +import Notion from "./pages/static/Notion.tsx"; +import { ThemeContext, useThemeService } from "./services/theme/index.ts"; -/** - * App コンポーネントは、アプリケーション全体のレイアウトを定義します。 - * - * - テーマ(light/dark)の切り替え機能を提供します。 - * - ユーザー情報を管理し、アプリ全体で利用可能にします。 - * @returns アプリケーションのルートコンポーネント - */ export default function App() { - /** - * テーマ管理 - */ - const themeService = useThemeProvider(); - - const userInstance = new UserService(); - const [user, setUserState] = useState( - userInstance.getUser(), - ); - - const setUser = (newUser: User) => { - userInstance.setUser(newUser); - setUserState(newUser); - }; - - const userContextValue: UserContextValue = { - user, - setUser, - }; + const themeService = useThemeService(); return ( - - -
- -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
-
-
-
-
+ +
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+
+
); } diff --git a/packages/web/src/components/ClassModal/index.tsx b/packages/web/src/components/ClassModal/index.tsx index 4036a48..210d1c0 100644 --- a/packages/web/src/components/ClassModal/index.tsx +++ b/packages/web/src/components/ClassModal/index.tsx @@ -1,9 +1,5 @@ import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react"; -import { - type ClassDataType, - type DayPeriod, - dayMapping, -} from "@packages/models"; +import { type Course, type DayPeriod, dayMapping } from "@packages/models"; import { FiX } from "react-icons/fi"; import Item from "./ClassModalItem.tsx"; @@ -25,7 +21,7 @@ interface ModalProps { /** * 授業の情報 */ - classData: ClassDataType; + classData: Course; } /** diff --git a/packages/web/src/components/DataLoadingIndicator.tsx b/packages/web/src/components/DataLoadingIndicator.tsx new file mode 100644 index 0000000..d34d23f --- /dev/null +++ b/packages/web/src/components/DataLoadingIndicator.tsx @@ -0,0 +1,18 @@ +import { useCourseDataStatus } from "@/services/courses/data"; + +export default function DataLoadingIndicator({ + className, +}: { + className?: string; +}) { + const courseData = useCourseDataStatus(); + return ( +
+ {courseData.status === "loading" &&

Loading courses...

} + {courseData.status === "error" &&

Error loading courses

} + {courseData.status === "success" && ( +

Fetched {Object.keys(courseData.data).length} courses

+ )} +
+ ); +} diff --git a/packages/web/src/components/FilterUI/FilterComponents/ClassType.tsx b/packages/web/src/components/FilterUI/FilterComponents/ClassType.tsx index 2f6a5cd..dd16970 100644 --- a/packages/web/src/components/FilterUI/FilterComponents/ClassType.tsx +++ b/packages/web/src/components/FilterUI/FilterComponents/ClassType.tsx @@ -1,64 +1,52 @@ -/* - * セメスターフィルターのコンポーネント - */ - -import type { ClassType } from "@packages/models"; -import type React from "react"; +import type { ClassSeries } from "@packages/models"; import { FlagButton } from "../UI/FlagButton.tsx"; -/** - * クラス種別フィルターのプロパティ - */ -interface SemesterProp { - selectedClassTypes?: ClassType[]; - setSelectedClassTypes: (classType: ClassType[]) => void; -} - -const ClassType1: ClassType[] = ["基礎", "要求", "主題", "展開"]; -const ClassType2: ClassType[] = ["L", "A", "B", "C", "D", "E", "F"]; +const ClassType: ClassSeries[] = ["基礎", "要求", "主題", "展開"]; +const GeneralSeries = ["L", "A", "B", "C", "D", "E", "F"] as const; /** - * 種別フィルターのコンポーネント - * @param prop 種別フィルターのプロパティ - * @returns コンポーネント + * 成績種別・系列でフィルターする */ -export const ClassTypeFilter: React.FC = (prop: SemesterProp) => { - const selectedClassTypes = prop.selectedClassTypes ?? []; +export function ClassSeriesFilter(props: { + selectedClassSeries?: ClassSeries[]; + setSelectedClassSeries: (classSeries: ClassSeries[]) => void; +}) { + const selectedClassSeries = props.selectedClassSeries ?? []; // ボタンがクリックされたときの関数 - const onClick = (classType: ClassType) => { - if (selectedClassTypes.includes(classType)) { + const onClick = (classSeries: ClassSeries) => { + if (selectedClassSeries.includes(classSeries)) { // 既に含まれている場合、除外 - prop.setSelectedClassTypes( - selectedClassTypes.filter((c) => c !== classType), + props.setSelectedClassSeries( + selectedClassSeries.filter((c) => c !== classSeries), ); } else { // 含まれていた場合、追加 - prop.setSelectedClassTypes([...selectedClassTypes, classType]); + props.setSelectedClassSeries([...selectedClassSeries, classSeries]); } }; return (
- {ClassType1.map((c) => ( + {ClassType.map((c) => ( onClick(c)} className="col-span-2" /> ))} - {ClassType2.map((c) => ( + {GeneralSeries.map((c) => ( onClick(c)} + isSelected={selectedClassSeries.includes(`総合${c}`)} + onClick={() => onClick(`総合${c}`)} className="aspect-square col-span-1" /> ))}
); -}; +} diff --git a/packages/web/src/components/FilterUI/FilterUI.tsx b/packages/web/src/components/FilterUI/FilterUI.tsx index 5080fc1..b0db8a6 100644 --- a/packages/web/src/components/FilterUI/FilterUI.tsx +++ b/packages/web/src/components/FilterUI/FilterUI.tsx @@ -2,32 +2,25 @@ * 全てのフィルターを表示するコンポーネント */ -import type { ClassType, Evaluation, Semester } from "@packages/models"; +import type { ClassSeries, Evaluation, Semester } from "@packages/models"; import { useState } from "react"; -import { ClassTypeFilter } from "./FilterComponents/ClassType.tsx"; +import { ClassSeriesFilter } from "./FilterComponents/ClassType.tsx"; import { EvaluationFilter } from "./FilterComponents/Evaluation.tsx"; import { Freeword } from "./FilterComponents/Freeword.tsx"; import { RegistrationFilter } from "./FilterComponents/RegistrationFilter.tsx"; import { SemestersCheckbox } from "./FilterComponents/Semester.tsx"; import { FilterCard } from "./UI/FilterCard.tsx"; -/** - * フィルタの型定義 - */ type Filter = { isFreewordForSyllabusDetail?: boolean; // フリーワード検索 semesters?: Semester[]; // セメスター evaluation_included?: Evaluation[]; // 含めたい評価方法 evaluation_excluded?: Evaluation[]; // 除外したい評価方法 - classTypes?: ClassType[]; // 種別 + series?: ClassSeries[]; // 種別・系列 showRegistered?: boolean; // 履修登録済みの授業を表示する showNotRegistered?: boolean; // 未履修の授業を表示する }; -/** - * フィルタUI - * @returns フィルタUI - */ export const FilterUI: React.FC = () => { // 現在のフィルター const [filter, setFilter] = useState({}); @@ -66,10 +59,10 @@ export const FilterUI: React.FC = () => { - - setFilter({ ...filter, classTypes }) + + setFilter({ ...filter, series }) } /> diff --git a/packages/web/src/components/FilterUI/Sample/page.tsx b/packages/web/src/components/FilterUI/Sample/page.tsx deleted file mode 100644 index 9e7a2ad..0000000 --- a/packages/web/src/components/FilterUI/Sample/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/* - * FilterUIのサンプルページ - */ - -import { FilterUI } from "../FilterUI.tsx"; - -const FilterUISample: React.FC = () => { - return ; -}; - -export default FilterUISample; diff --git a/packages/web/src/components/Sample/ClassModal/SampleClassData.ts b/packages/web/src/components/Sample/ClassModal/SampleClassData.ts index 4aac40c..9bb056c 100644 --- a/packages/web/src/components/Sample/ClassModal/SampleClassData.ts +++ b/packages/web/src/components/Sample/ClassModal/SampleClassData.ts @@ -1,14 +1,9 @@ -import type { ClassDataType } from "@packages/models"; +import type { Course } from "@packages/models"; /** * 講義詳細モーダルの動作確認に利用するサンプルデータ */ -export const SampleClasses: [ - ClassDataType, - ClassDataType, - ClassDataType, - ClassDataType, -] = [ +export const SampleClasses: [Course, Course, Course, Course] = [ { code: "30003", type: "基礎", diff --git a/packages/web/src/components/Sample/LoadClasses/page.tsx b/packages/web/src/components/Sample/LoadClasses/page.tsx deleted file mode 100644 index d58c390..0000000 --- a/packages/web/src/components/Sample/LoadClasses/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * /class_data に、現状のシ楽バスのスクレイピングデータを新シ楽バス用に変換したデータを置いている - * このページで変換後のデータを問題なく利用できることを確認する - */ -import * as classData from "@packages/class_data/data/new/2024A.json"; -import type { ClassDataType } from "@packages/models"; - -/** - * /class_data により生成された授業情報が機能することを確認するコンポーネント - * @returns コンポーネント - */ -const LoadClass: React.FC = () => { - // 読み取り - const classes = classData as ClassDataType[]; - - // 1つ授業を取り出し、再度Jsonに変換 - // ( classes[64] には、全ての授業情報プロパティが入力されている ) - const json = JSON.stringify(classes[64], null, 4); - - // 表示 - return

{json}

; -}; - -export default LoadClass; diff --git a/packages/web/src/components/Sample/Timetable/page.tsx b/packages/web/src/components/Sample/Timetable/page.tsx index c6b796a..fc77032 100644 --- a/packages/web/src/components/Sample/Timetable/page.tsx +++ b/packages/web/src/components/Sample/Timetable/page.tsx @@ -1,4 +1,4 @@ -import type { ClassDataType } from "@packages/models"; +import type { Course } from "@packages/models"; import { useState } from "react"; import ClassModalComponent from "../../ClassModal/index.tsx"; import TimetableFrame from "../../Timetable/timetableFrame.tsx"; @@ -26,8 +26,8 @@ const TimetableComponentSample: React.FC = () => { * @param classes このスロットに表示する関数 * @returns スロット内の要素 */ -function ClassSlotElement(classes: ClassDataType[]) { - const [classForModal, setClassForModal] = useState(); +function ClassSlotElement(classes: Course[]) { + const [classForModal, setClassForModal] = useState(); return ( <> diff --git a/packages/web/src/components/Timetable/slots/classSlot.tsx b/packages/web/src/components/Timetable/slots/classSlot.tsx index 80101cd..098dc9b 100644 --- a/packages/web/src/components/Timetable/slots/classSlot.tsx +++ b/packages/web/src/components/Timetable/slots/classSlot.tsx @@ -1,4 +1,4 @@ -import type { ClassDataType, DayPeriod } from "@packages/models"; +import type { Course, DayPeriod } from "@packages/models"; import type React from "react"; import { SlotDiv, type slotProps } from "./slot.tsx"; @@ -8,9 +8,9 @@ import { SlotDiv, type slotProps } from "./slot.tsx"; interface classProps extends slotProps { day_period: DayPeriod | "集中"; // 曜限 hasSaturday: boolean; // 土曜日表示か否か - classes: ClassDataType[]; // このスロットに表示したいクラス + classes: Course[]; // このスロットに表示したいクラス isIntensiveClass: boolean; // このスロットが集中講義か否か - classSlotElement: (classes: ClassDataType[]) => React.ReactNode; //講義スロット内に配置する要素 + classSlotElement: (classes: Course[]) => React.ReactNode; //講義スロット内に配置する要素 } /** diff --git a/packages/web/src/components/Timetable/timetableFrame.tsx b/packages/web/src/components/Timetable/timetableFrame.tsx index fa78b0e..9ad9066 100644 --- a/packages/web/src/components/Timetable/timetableFrame.tsx +++ b/packages/web/src/components/Timetable/timetableFrame.tsx @@ -9,7 +9,7 @@ * 使用例: components/Sample/Timetable/page */ -import type { ClassDataType, Day, DayPeriod } from "@packages/models"; +import type { Course, Day, DayPeriod } from "@packages/models"; import type React from "react"; import { type ReactElement, useEffect, useState } from "react"; import { SampleClasses } from "../Sample/ClassModal/SampleClassData.ts"; @@ -28,7 +28,7 @@ interface TimetableProps { // 講義スロット内のデザイン // このスロットに表示したい講義はこのコンポーネントで解決し // デザインだけ外部(classSlotElementの内容)に任せる - classSlotElement: (classes: ClassDataType[]) => React.ReactNode; + classSlotElement: (classes: Course[]) => React.ReactNode; // 時限ヘッダー内のデザイン // 詳細はclassSlotElementと同じ @@ -44,7 +44,7 @@ interface TimetableProps { * 【さしあたりサンプルの講義を用いる】 * @returns 講義のコレクション */ -async function loadClass(): Promise { +async function loadClass(): Promise { return SampleClasses; } @@ -54,7 +54,7 @@ async function loadClass(): Promise { * @param dayPeriod 検索対象の曜限 * @returns 指定の曜限に開講される授業全て */ -function findClasses(classes: ClassDataType[], dayPeriod: DayPeriod | "集中") { +function findClasses(classes: Course[], dayPeriod: DayPeriod | "集中") { // i番目の講義が、指定の曜限(dayPeriod)に開講されているか否かを判定する関数 const predicate = (i: number) => { // 集中講義が検索されているとき @@ -96,7 +96,7 @@ function findClasses(classes: ClassDataType[], dayPeriod: DayPeriod | "集中") */ const Timetable: React.FC = (props: TimetableProps) => { // 時間割に表示したい講義 - const [classes, setClasses] = useState([]); + const [classes, setClasses] = useState([]); // 時間割表示時、ユーザーが履修している講義を取得 useEffect(() => { diff --git a/packages/web/src/components/auth/GoogleLoginButton.tsx b/packages/web/src/components/auth/GoogleLoginButton.tsx new file mode 100644 index 0000000..789a72d --- /dev/null +++ b/packages/web/src/components/auth/GoogleLoginButton.tsx @@ -0,0 +1,48 @@ +import { signInWithGoogle } from "@/lib/auth-client"; + +// TODO: style it properly +export function GoogleLoginButton({ callbackURL }: { callbackURL: string }) { + return ( + + ); +} + +function GoogleLogo() { + return ( + + Google logo + + + + + + + + + ); +} diff --git a/packages/web/src/components/auth/LogOutButton.tsx b/packages/web/src/components/auth/LogOutButton.tsx new file mode 100644 index 0000000..c5c2b58 --- /dev/null +++ b/packages/web/src/components/auth/LogOutButton.tsx @@ -0,0 +1,9 @@ +import { logout } from "@/lib/auth-client"; + +export const LogOutButton = () => { + return ( + + ); +}; diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index ff0684a..c8473c2 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -1,5 +1,4 @@ import { edenTreaty } from "@elysiajs/eden"; import type { App } from "@packages/server"; -import { env } from "./env.ts"; -export const api = edenTreaty(env.serverURL).api; +export const api = edenTreaty(window.location.origin, {}).api; diff --git a/packages/web/src/lib/async-types.ts b/packages/web/src/lib/async-types.ts new file mode 100644 index 0000000..85ef882 --- /dev/null +++ b/packages/web/src/lib/async-types.ts @@ -0,0 +1,12 @@ +export type AsyncState = + | { + status: "loading"; + } + | { + status: "success"; + data: T; + } + | { + status: "error"; + error: Error; + }; diff --git a/packages/web/src/lib/auth-client.ts b/packages/web/src/lib/auth-client.ts index 5825bb9..f06c45d 100644 --- a/packages/web/src/lib/auth-client.ts +++ b/packages/web/src/lib/auth-client.ts @@ -1,5 +1,16 @@ import { createAuthClient } from "better-auth/react"; -export const authClient = createAuthClient({ - baseURL: import.meta.env.PUBLIC_SERVER_URL, +export const auth = createAuthClient({ + baseURL: window.location.origin, }); + +export async function logout() { + await auth.signOut(); +} + +export async function signInWithGoogle(callbackURL: string) { + await auth.signIn.social({ + provider: "google", + callbackURL, + }); +} diff --git a/packages/web/src/lib/env.ts b/packages/web/src/lib/env.ts index 8649b6a..050529f 100644 --- a/packages/web/src/lib/env.ts +++ b/packages/web/src/lib/env.ts @@ -1,11 +1,9 @@ export const env = { mockData: boolean(import.meta.env.PUBLIC_MOCK_DATA), - serverURL: string(import.meta.env.PUBLIC_SERVER_URL, { - name: "PUBLIC_SERVER_URL", - }), + dev: import.meta.env.DEV, }; -function boolean(value: string | undefined): boolean { +export function boolean(value: string | undefined): boolean { return value === "true" || value === "1"; } @@ -17,7 +15,7 @@ function boolean(value: string | undefined): boolean { * @param param1 * @returns 結果 */ -function string( +export function string( value: string | undefined, { name, defaultValue }: { name?: string; defaultValue?: string }, ): string { diff --git a/packages/web/src/services/user/mock_data.ts b/packages/web/src/lib/mock/mock-data.ts similarity index 98% rename from packages/web/src/services/user/mock_data.ts rename to packages/web/src/lib/mock/mock-data.ts index 7eb2a8e..55c6560 100644 --- a/packages/web/src/services/user/mock_data.ts +++ b/packages/web/src/lib/mock/mock-data.ts @@ -1,13 +1,14 @@ -import type { ClassDataType, User } from "@packages/models"; +import type { Course, UserData } from "@packages/models"; /** * 講義詳細モーダルの動作確認に利用するサンプルデータ */ -export const SampleClasses: ClassDataType[] = [ +export const sampleClasses: Course[] = [ { code: "30003", type: "基礎", category: "数理科学", + shortenedCategory: "基礎", semester: "S1", dayPeriod: [ { day: "mon", period: 1 }, @@ -57,7 +58,6 @@ export const SampleClasses: ClassDataType[] = [ [], ], importance: [["l1", "l2", "l3", "s1", "s2", "s3"], []], - shortenedCategory: "基礎", shortenedEvaluation: "試験レポ平常", shortenedClassroom: "523", }, @@ -65,6 +65,7 @@ export const SampleClasses: ClassDataType[] = [ code: "40303", type: "基礎", category: "基礎実験", + shortenedCategory: "基礎", semester: "S2", dayPeriod: "集中", classroom: "その他(学内等) シラバス参照", @@ -92,7 +93,6 @@ export const SampleClasses: ClassDataType[] = [ timeCompensation: "", targetClass: [[], ["s1_all", "s2_all", "s3_all"]], importance: [["l1", "l2", "l3", "s1", "s2", "s3"], []], - shortenedCategory: "基礎", shortenedEvaluation: "レポ出席", shortenedClassroom: "他(学内等)", }, @@ -100,6 +100,7 @@ export const SampleClasses: ClassDataType[] = [ code: "50033", type: "基礎", category: "既修外国語", + shortenedCategory: "基礎", semester: "A", dayPeriod: [ { @@ -134,7 +135,6 @@ export const SampleClasses: ClassDataType[] = [ [], ], importance: [["l1", "l2", "l3", "s1", "s2", "s3"], []], - shortenedCategory: "基礎", shortenedEvaluation: "レポ出席平常", shortenedClassroom: "516", }, @@ -142,6 +142,7 @@ export const SampleClasses: ClassDataType[] = [ code: "51320", type: "総合", category: "A(思想・芸術)", + shortenedCategory: "総合A", semester: "A", dayPeriod: [ { @@ -178,14 +179,14 @@ export const SampleClasses: ClassDataType[] = [ ["l1_all", "l2_all", "l3_all", "s1_all", "s2_all", "s3_all"], ], importance: [[], []], - shortenedCategory: "総合A", shortenedEvaluation: "レポ", shortenedClassroom: "1232", }, ]; -export const SampleUser: User = { +export const sampleUser: UserData = { stream: "l1", grade: 1, classNumber: 1, + courses: ["30003", "51320"], }; diff --git a/packages/web/src/lib/tanstack/keys.ts b/packages/web/src/lib/tanstack/keys.ts index dec2146..241f3cc 100644 --- a/packages/web/src/lib/tanstack/keys.ts +++ b/packages/web/src/lib/tanstack/keys.ts @@ -3,4 +3,9 @@ export const keys = { _: ["users_sample"], list: ["users_sample", "list"], }, + + users: { + _: ["users"], + currentUser: ["users", "current"], + }, } as const; diff --git a/packages/web/src/lib/utils/normalize-error.ts b/packages/web/src/lib/utils/normalize-error.ts new file mode 100644 index 0000000..257a55c --- /dev/null +++ b/packages/web/src/lib/utils/normalize-error.ts @@ -0,0 +1,6 @@ +export function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error("Unknown error", { cause: error }); +} diff --git a/packages/web/src/lib/utils/sleep.ts b/packages/web/src/lib/utils/sleep.ts new file mode 100644 index 0000000..421bda0 --- /dev/null +++ b/packages/web/src/lib/utils/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/web/src/lib/utils/stores.ts b/packages/web/src/lib/utils/stores.ts new file mode 100644 index 0000000..3a2e4ca --- /dev/null +++ b/packages/web/src/lib/utils/stores.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { get, type Writable } from "svelte/store"; + +export function useStoreEffect( + store: Writable, + listener: (value: T) => void, +) { + // biome-ignore lint/correctness/useExhaustiveDependencies: false positive - if I add listener, it cannot be inlined + useEffect(() => { + const unsubscribe = store.subscribe((value) => { + listener(value); + }); + return () => unsubscribe(); + }, [store]); +} + +export function useStore(store: Writable) { + const [value, setValue] = useState(get(store)); + useStoreEffect(store, setValue); + return value; +} diff --git a/packages/web/src/pages/Profile.tsx b/packages/web/src/pages/Profile.tsx index d415a3e..90f3682 100644 --- a/packages/web/src/pages/Profile.tsx +++ b/packages/web/src/pages/Profile.tsx @@ -1,11 +1,33 @@ -import { authClient } from "@/lib/auth-client"; +import { GoogleLoginButton } from "@/components/auth/GoogleLoginButton"; +import { LogOutButton } from "@/components/auth/LogOutButton"; +import { useCurrentUserQuery } from "@/services/user/user"; export default function Profile() { - const { data: session } = authClient.useSession(); - const username = session?.user.name; + const user = useCurrentUserQuery(); + + if (user.isPending) { + return
Loading...
; + } + + if (!user.data) { + return ( +
+ Hello anonymous user +
+ ); + } + return (
- {username ?
Hello {username}
:
Who are you?
} +
Hello {user.data.name}
+ {user.data.image && ( + profile + )} +
); } diff --git a/packages/web/src/pages/Setup.tsx b/packages/web/src/pages/Setup.tsx new file mode 100644 index 0000000..15aaa21 --- /dev/null +++ b/packages/web/src/pages/Setup.tsx @@ -0,0 +1,8 @@ +export default function SetupPage() { + return ( +
+

Setup your preference!

+ (todo) +
+ ); +} diff --git a/packages/web/src/pages/SignIn.tsx b/packages/web/src/pages/SignIn.tsx index bf21560..3dcf31b 100644 --- a/packages/web/src/pages/SignIn.tsx +++ b/packages/web/src/pages/SignIn.tsx @@ -1,23 +1,11 @@ -import { authClient } from "@/lib/auth-client"; +import { GoogleLoginButton } from "@/components/auth/GoogleLoginButton"; +// TODO: style it +// TODO: if user is already logged in, go to /profile export default function SignIn() { - const handleClick = async () => { - try { - await authClient.signIn.social({ - provider: "google", - // Use an absolute path for the callbackURL to prevent redirecting to the server. - callbackURL: `${window.location.origin}/profile`, - }); - } catch (error) { - console.error("error", error); - } - }; - return (
- +
); } diff --git a/packages/web/src/pages/AboutUs.tsx b/packages/web/src/pages/static/AboutUs.tsx similarity index 75% rename from packages/web/src/pages/AboutUs.tsx rename to packages/web/src/pages/static/AboutUs.tsx index ece8452..2147d2b 100644 --- a/packages/web/src/pages/AboutUs.tsx +++ b/packages/web/src/pages/static/AboutUs.tsx @@ -11,11 +11,13 @@ const AboutUs: React.FC = () => { 私たち ut.code(); は、2019年度に発足した東京大学のプログラミングサークルです。主にWeb関連の技術を用い、私たちの生活を彩るプログラムを開発することを目標としています。
- LINE add friend + + X(旧Twitter) +
- X(旧Twitter) -
- Webサイト + + Webサイト + ); }; diff --git a/packages/web/src/pages/Disclaimer.tsx b/packages/web/src/pages/static/Disclaimer.tsx similarity index 100% rename from packages/web/src/pages/Disclaimer.tsx rename to packages/web/src/pages/static/Disclaimer.tsx diff --git a/packages/web/src/pages/Home.tsx b/packages/web/src/pages/static/LP.tsx similarity index 83% rename from packages/web/src/pages/Home.tsx rename to packages/web/src/pages/static/LP.tsx index b0384b4..3623817 100644 --- a/packages/web/src/pages/Home.tsx +++ b/packages/web/src/pages/static/LP.tsx @@ -1,14 +1,11 @@ -import type React from "react"; import { Link } from "react-router-dom"; +import DataLoadingIndicator from "@/components/DataLoadingIndicator"; import Logo from "/syllabus_icon.svg"; -// import { useUser } from "@/app/UserContext"; /** - * Home コンポーネントは、ホームページの内容を表示します。 - * @returns HTMLを生成するReactコンポーネント。 + * ランディングページ。 */ -export default function Home(): React.ReactElement { - // const { user, setUser } = useUser(); +export default function LandingPage() { return ( <> {/* バックグラウンド画像 */} @@ -55,10 +52,11 @@ export default function Home(): React.ReactElement { {/* はじめるボタン */} -
+
サインインページへ +
diff --git a/packages/web/src/pages/NotFound.tsx b/packages/web/src/pages/static/NotFound.tsx similarity index 100% rename from packages/web/src/pages/NotFound.tsx rename to packages/web/src/pages/static/NotFound.tsx diff --git a/packages/web/src/pages/Notion.tsx b/packages/web/src/pages/static/Notion.tsx similarity index 100% rename from packages/web/src/pages/Notion.tsx rename to packages/web/src/pages/static/Notion.tsx diff --git a/packages/web/src/pages/how-to-use/HowToUseItem.tsx b/packages/web/src/pages/static/how-to-use/HowToUseItem.tsx similarity index 100% rename from packages/web/src/pages/how-to-use/HowToUseItem.tsx rename to packages/web/src/pages/static/how-to-use/HowToUseItem.tsx diff --git a/packages/web/src/pages/how-to-use/HowToUseItem2.tsx b/packages/web/src/pages/static/how-to-use/HowToUseItem2.tsx similarity index 100% rename from packages/web/src/pages/how-to-use/HowToUseItem2.tsx rename to packages/web/src/pages/static/how-to-use/HowToUseItem2.tsx diff --git a/packages/web/src/pages/how-to-use/page.tsx b/packages/web/src/pages/static/how-to-use/page.tsx similarity index 100% rename from packages/web/src/pages/how-to-use/page.tsx rename to packages/web/src/pages/static/how-to-use/page.tsx diff --git a/packages/web/src/services/courses/data.ts b/packages/web/src/services/courses/data.ts new file mode 100644 index 0000000..98ed949 --- /dev/null +++ b/packages/web/src/services/courses/data.ts @@ -0,0 +1,30 @@ +import type { CourseCollection } from "@packages/models"; +import { useEffect, useState } from "react"; +import type { AsyncState } from "@/lib/async-types.ts"; +import { courseStore } from "./loader.ts"; + +export function getCourseData(): Promise { + const { resolve, promise } = Promise.withResolvers(); + const unsubscribe = courseStore.subscribe((result) => { + if (result.status === "success") { + resolve(result.data); + unsubscribe(); + } + }); + return promise; +} + +export function useCourseDataStatus(): AsyncState { + const [state, setState] = useState>({ + status: "loading", + }); + + useEffect(() => { + const unsubscribe = courseStore.subscribe((result) => { + setState(result); + }); + return () => unsubscribe(); + }, []); + + return state; +} diff --git a/packages/web/src/services/courses/loader.ts b/packages/web/src/services/courses/loader.ts new file mode 100644 index 0000000..629a8f4 --- /dev/null +++ b/packages/web/src/services/courses/loader.ts @@ -0,0 +1,57 @@ +import type { CourseCollection, CourseList } from "@packages/models"; +import { courseListToCourseCollection } from "@packages/models/transformer"; +import { writable } from "svelte/store"; +import { api } from "@/lib/api.ts"; +import type { AsyncState } from "@/lib/async-types.ts"; +import { env } from "@/lib/env.ts"; +import { sampleClasses } from "@/lib/mock/mock-data.ts"; +import { normalizeError } from "@/lib/utils/normalize-error.ts"; +import { sleep } from "@/lib/utils/sleep.ts"; + +// TODO: implement retrying +// (or use Tanstack Query? like this? +// https://tanstack.com/query/v4/docs/framework/react/guides/prefetching) + +async function load(): Promise { + if (env.mockData) { + await sleep(500); + if (Math.random() > 0.5) { + throw new Error("Failed to load courses: Math.random returned > 0.5"); + } + return sampleClasses; + } + + // real data + const { data, error } = await api.courses.get({ + $query: { + name: "2024A", + }, + }); + + if (error) { + throw error; + } + + return data; +} + +export const courseStore = writable>({ + status: "loading", +}); + +(async () => { + try { + const data = await load(); + console.log("fetched", data.length, "data"); + const courseCollection = courseListToCourseCollection(data); + courseStore.set({ + status: "success", + data: courseCollection, + }); + } catch (error) { + courseStore.set({ + status: "error", + error: normalizeError(error), + }); + } +})(); diff --git a/packages/web/src/services/theme/index.ts b/packages/web/src/services/theme/index.ts index d2dc8ed..0cf8bae 100644 --- a/packages/web/src/services/theme/index.ts +++ b/packages/web/src/services/theme/index.ts @@ -2,13 +2,9 @@ import { createContext, useContext, useEffect, useState } from "react"; import type { ThemeService } from "@/services/theme/types"; import type { Theme } from "./types.ts"; -/** - * テーマコンテキスト - * dark modeとlight modeの切り替えをHeaderでするためのContext - */ export const ThemeContext = createContext(null); -export function useThemeProvider() { +export function useThemeService() { const [theme, setTheme] = useState("light"); // the only proper use of useEffect diff --git a/packages/web/src/services/user/UserContext.ts b/packages/web/src/services/user/UserContext.ts deleted file mode 100644 index 0afc584..0000000 --- a/packages/web/src/services/user/UserContext.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { User } from "@packages/models"; -import { createContext, useContext } from "react"; - -/** - * UserContext で管理するデータの型定義 - */ -export type UserContextValue = { - user: User | undefined; - setUser: (newUser: User) => void; -}; - -/** - * ユーザー情報を提供するContext - */ -export const UserContext = createContext(null); - -/** - * カスタムフック: useUser - * @returns UserContextValue - ユーザー情報とその更新関数を提供するオブジェクト - */ -export const useUser = () => { - const context = useContext(UserContext); - if (!context) { - throw new Error("useUser must be used within a UserProvider"); - } - return context; -}; diff --git a/packages/web/src/services/user/user.ts b/packages/web/src/services/user/user.ts index 1b5f6fc..7daf61d 100644 --- a/packages/web/src/services/user/user.ts +++ b/packages/web/src/services/user/user.ts @@ -1,43 +1,36 @@ -import type { User } from "@packages/models"; -import { SampleUser } from "@/services/user/mock_data.ts"; -import { env } from "../../lib/env.ts"; +import type { UserData } from "@packages/models"; +import { useMutation } from "@tanstack/react-query"; +import { env } from "elysia"; +import { api } from "@/lib/api"; +import { auth } from "@/lib/auth-client.ts"; +import { queryClient } from "@/lib/tanstack/client.ts"; +import { keys } from "@/lib/tanstack/keys"; +import { sleep } from "@/lib/utils/sleep"; -// TODO: サーバーとデータを送受信する -export class UserService { - private user: User | undefined; +export const useCurrentUserQuery = () => { + const session = auth.useSession(); + return { + ...session, + data: session.data?.user, + }; +}; - constructor() { - if (typeof window !== "undefined") { - if (env.mockData) { - this.user = SampleUser; - } else { - const storedUser = localStorage.getItem("user"); - this.user = storedUser ? JSON.parse(storedUser) : undefined; +export const useUpdateUserMutation = () => { + return useMutation({ + mutationFn: async (data: UserData) => { + // TODO: update user in-memory if it's mocked + if (env.dev) { + await sleep(500); } - } else { - this.user = undefined; - } - } - - /** - * ユーザー情報を取得します。 - * undefinedの場合は、未登録とみなします。 - * @returns 現在のユーザー情報 - */ - getUser(): User | undefined { - return this.user; - } - - /** - * ユーザー情報を更新します。 - * クライアントサイドのみで localStorage を更新します。 - * @param newUser 新しいユーザー情報 - */ - setUser(newUser: User): void { - this.user = newUser; - - if (typeof window !== "undefined" && !env.mockData) { - localStorage.setItem("user", JSON.stringify(newUser)); - } - } -} + const { error } = await api.users.me.data.put(data); + if (error) { + throw error; + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: keys.users.currentUser, + }); + }, + }); +}; diff --git a/packages/web/src/stories/FilterUI.stories.tsx b/packages/web/src/stories/FilterUI.stories.tsx new file mode 100644 index 0000000..3fb29e7 --- /dev/null +++ b/packages/web/src/stories/FilterUI.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FilterUI } from "@/components/FilterUI/FilterUI.tsx"; + +const meta = { + title: "Components/FilterUI", + component: FilterUI, + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: `A filter UI component that allows users to filter courses based on various criteria. + Includes filters for free text search, semesters, evaluation methods, class types, and registration status.`, + }, + }, + }, + argTypes: { + // No direct props since FilterUI manages its own state internally + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Default story showing the FilterUI with no initial filters +const Template: Story = { + render: () => , +}; + +export const Default = { + ...Template, + args: {}, +}; diff --git a/packages/web/src/stories/Timetable.stories.tsx b/packages/web/src/stories/Timetable.stories.tsx index 73cd010..fdab3b9 100644 --- a/packages/web/src/stories/Timetable.stories.tsx +++ b/packages/web/src/stories/Timetable.stories.tsx @@ -1,4 +1,4 @@ -import type { ClassDataType } from "@packages/models"; +import type { Course } from "@packages/models"; import type { Meta, StoryObj } from "@storybook/react"; import Timetable from "@/components/Timetable/timetableFrame"; @@ -16,7 +16,7 @@ export default meta; type Story = StoryObj; // Helper component for class slots -const ClassSlotElement = (classes: ClassDataType[]) => { +const ClassSlotElement = (classes: Course[]) => { return (
{classes.map((c, i) => ( diff --git a/packages/web/src/vite-env.d.ts b/packages/web/src/vite-env.d.ts index fd39b0d..11f02fe 100644 --- a/packages/web/src/vite-env.d.ts +++ b/packages/web/src/vite-env.d.ts @@ -1,10 +1 @@ /// - -interface ImportMetaEnv { - readonly PUBLIC_SERVER_URL: string; - readonly PUBLIC_MOCK_DATA: string; -} - -interface ImportMeta { - readonly env: ImportMetaEnv; -} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 8c1cbce..f8f768c 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -12,6 +12,21 @@ export default defineConfig({ envDir: "../../", envPrefix: "PUBLIC_", server: { - port: 3000, + port: getPort(getEnv("BASE_URL")), + proxy: { + "/api": { + target: `http://localhost:${getEnv("EXTERNAL_SERVER_PORT")}`, + }, + }, }, }); + +function getEnv(name: string): string { + const val = process.env[name]; + if (val === undefined) + throw new Error(`Environment variable not found: ${name}`); + return val; +} +function getPort(url: string): number { + return parseInt(new URL(url).port); +}