diff --git a/.gitignore b/.gitignore index b512c09..7bdd2d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -node_modules \ No newline at end of file +node_modules +.output +.nitro +dist \ No newline at end of file diff --git a/Frontend/README.md b/Frontend/README.md new file mode 100644 index 0000000..5ac8b51 --- /dev/null +++ b/Frontend/README.md @@ -0,0 +1,13 @@ +Poppins for Title +Caveat_Brush for Quotes + +Black #1D1D1B + +purple-100 #F3EEFB +purple-200 #ECE5F6 +purple-300 #C1A7E2 +purple-400 #7443DA +purple-500 #2D223B + +Green #00E456 +Yellow #FFF200 diff --git a/Frontend/package.json b/Frontend/package.json index 87ee57d..445f502 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -8,9 +8,11 @@ "preview": "vite preview" }, "dependencies": { + "@solidjs/router": "^0.16.1", "@solidjs/start": "2.0.0-alpha.2", "@solidjs/vite-plugin-nitro-2": "^0.1.0", "solid-js": "^1.9.5", + "solid-transition-group": "^0.3.0", "vite": "^7.0.0" }, "engines": { @@ -22,6 +24,7 @@ "cssnano": "^8.0.1", "postcss": "^8.5.14", "postcss-preset-env": "^11.2.1", - "sass": "^1.99.0" + "sass": "^1.99.0", + "sass-embedded": "^1.99.0" } } diff --git a/Frontend/pnpm-lock.yaml b/Frontend/pnpm-lock.yaml index 8580ca9..8163d03 100644 --- a/Frontend/pnpm-lock.yaml +++ b/Frontend/pnpm-lock.yaml @@ -8,18 +8,24 @@ importers: .: dependencies: + '@solidjs/router': + specifier: ^0.16.1 + version: 0.16.1(solid-js@1.9.11) '@solidjs/start': specifier: 2.0.0-alpha.2 - version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)) + version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)) '@solidjs/vite-plugin-nitro-2': specifier: ^0.1.0 - version: 0.1.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)) + version: 0.1.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)) solid-js: specifier: ^1.9.5 version: 1.9.11 + solid-transition-group: + specifier: ^0.3.0 + version: 0.3.0(solid-js@1.9.11) vite: specifier: ^7.0.0 - version: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0) + version: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0) devDependencies: '@types/node': specifier: ^25.6.2 @@ -39,6 +45,9 @@ importers: sass: specifier: ^1.99.0 version: 1.99.0 + sass-embedded: + specifier: ^1.99.0 + version: 1.99.0 packages: @@ -179,6 +188,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bufbuild/protobuf@2.12.0': + resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==} + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -1184,11 +1196,31 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@solid-primitives/refs@1.1.3': + resolution: {integrity: sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/transition-group@1.1.2': + resolution: {integrity: sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.4.0': + resolution: {integrity: sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==} + peerDependencies: + solid-js: ^1.6.12 + '@solidjs/meta@0.29.4': resolution: {integrity: sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g==} peerDependencies: solid-js: '>=1.8.4' + '@solidjs/router@0.16.1': + resolution: {integrity: sha512-IhyjedgC6LRpw/8CPGGI89FrV+r0xTHzOl2c4CRyzYQ1bLepJxbVI1LLKvsavMWY5TRBRacV7hAeOhuTXkjiqg==} + peerDependencies: + solid-js: ^1.8.6 + '@solidjs/start@2.0.0-alpha.2': resolution: {integrity: sha512-z56ATi3P07q8F5Io2I+RQrwjyWZtFZzpXN/J+8scf/gqrAW83LtgRkZFZjJaGH7i9WrHP+ep9F+ZiJ2gDHVBcw==} engines: {node: '>=22'} @@ -1511,6 +1543,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorjs.io@0.5.2: + resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -1931,6 +1966,10 @@ packages: crossws: optional: true + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2812,12 +2851,124 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + sass-embedded-all-unknown@1.99.0: + resolution: {integrity: sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==} + cpu: ['!arm', '!arm64', '!riscv64', '!x64'] + + sass-embedded-android-arm64@1.99.0: + resolution: {integrity: sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [android] + + sass-embedded-android-arm@1.99.0: + resolution: {integrity: sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [android] + + sass-embedded-android-riscv64@1.99.0: + resolution: {integrity: sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [android] + + sass-embedded-android-x64@1.99.0: + resolution: {integrity: sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [android] + + sass-embedded-darwin-arm64@1.99.0: + resolution: {integrity: sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [darwin] + + sass-embedded-darwin-x64@1.99.0: + resolution: {integrity: sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [darwin] + + sass-embedded-linux-arm64@1.99.0: + resolution: {integrity: sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + + sass-embedded-linux-arm@1.99.0: + resolution: {integrity: sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + + sass-embedded-linux-musl-arm64@1.99.0: + resolution: {integrity: sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + + sass-embedded-linux-musl-arm@1.99.0: + resolution: {integrity: sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + + sass-embedded-linux-musl-riscv64@1.99.0: + resolution: {integrity: sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + + sass-embedded-linux-musl-x64@1.99.0: + resolution: {integrity: sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + + sass-embedded-linux-riscv64@1.99.0: + resolution: {integrity: sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + + sass-embedded-linux-x64@1.99.0: + resolution: {integrity: sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + + sass-embedded-unknown-all@1.99.0: + resolution: {integrity: sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==} + os: ['!android', '!darwin', '!linux', '!win32'] + + sass-embedded-win32-arm64@1.99.0: + resolution: {integrity: sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [win32] + + sass-embedded-win32-x64@1.99.0: + resolution: {integrity: sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [win32] + + sass-embedded@1.99.0: + resolution: {integrity: sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==} + engines: {node: '>=16.0.0'} + hasBin: true + sass@1.99.0: resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==} engines: {node: '>=14.0.0'} @@ -2897,6 +3048,12 @@ packages: peerDependencies: solid-js: ^1.3 + solid-transition-group@0.3.0: + resolution: {integrity: sha512-gzFbtxkEnA8Hgi7UzmEjPPRl4rodoKLs+AGIXA7kJgjOr4j4qFRw39npu+WpdNSDV7aDKr+K0LFcoT9Kbm5PBA==} + engines: {node: '>=20.0.0', pnpm: '>=9.0.0'} + peerDependencies: + solid-js: ^1.6.12 + solid-use@0.9.1: resolution: {integrity: sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw==} engines: {node: '>=10'} @@ -2989,6 +3146,10 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -2998,6 +3159,14 @@ packages: engines: {node: '>=16'} hasBin: true + sync-child-process@1.0.2: + resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==} + engines: {node: '>=16.0.0'} + + sync-message-port@1.2.0: + resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==} + engines: {node: '>=16.0.0'} + system-architecture@0.1.0: resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} engines: {node: '>=18'} @@ -3055,6 +3224,9 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@5.4.4: resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} engines: {node: '>=20'} @@ -3193,6 +3365,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + varint@6.0.0: + resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -3509,6 +3684,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bufbuild/protobuf@2.12.0': {} + '@cloudflare/kv-asset-handler@0.4.2': {} '@colordx/core@5.4.3': {} @@ -4310,17 +4487,34 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@solid-primitives/refs@1.1.3(solid-js@1.9.11)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.11) + solid-js: 1.9.11 + + '@solid-primitives/transition-group@1.1.2(solid-js@1.9.11)': + dependencies: + solid-js: 1.9.11 + + '@solid-primitives/utils@6.4.0(solid-js@1.9.11)': + dependencies: + solid-js: 1.9.11 + '@solidjs/meta@0.29.4(solid-js@1.9.11)': dependencies: solid-js: 1.9.11 - '@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))': + '@solidjs/router@0.16.1(solid-js@1.9.11)': + dependencies: + solid-js: 1.9.11 + + '@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))': dependencies: '@babel/core': 7.29.0 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 '@solidjs/meta': 0.29.4(solid-js@1.9.11) - '@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)) + '@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)) '@types/babel__traverse': 7.28.0 '@types/micromatch': 4.0.10 cookie-es: 2.0.0 @@ -4342,17 +4536,17 @@ snapshots: source-map-js: 1.2.1 srvx: 0.9.8 terracotta: 1.1.0(solid-js@1.9.11) - vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0) - vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0) + vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)) transitivePeerDependencies: - '@testing-library/jest-dom' - crossws - supports-color - '@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))': + '@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))': dependencies: nitropack: 2.13.1 - vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -4387,7 +4581,7 @@ snapshots: '@speed-highlight/core@1.2.14': {} - '@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))': + '@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 @@ -4397,7 +4591,7 @@ snapshots: babel-dead-code-elimination: 1.0.12 pathe: 2.0.3 tiny-invariant: 1.3.3 - vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0) transitivePeerDependencies: - supports-color @@ -4414,7 +4608,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))': + '@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 @@ -4423,7 +4617,7 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)) + '@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)) babel-dead-code-elimination: 1.0.12 tiny-invariant: 1.3.3 transitivePeerDependencies: @@ -4747,6 +4941,8 @@ snapshots: color-name@1.1.4: {} + colorjs.io@0.5.2: {} + comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -5178,6 +5374,8 @@ snapshots: optionalDependencies: crossws: 0.4.4(srvx@0.11.8) + has-flag@4.0.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -6204,10 +6402,101 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} + sass-embedded-all-unknown@1.99.0: + dependencies: + sass: 1.99.0 + optional: true + + sass-embedded-android-arm64@1.99.0: + optional: true + + sass-embedded-android-arm@1.99.0: + optional: true + + sass-embedded-android-riscv64@1.99.0: + optional: true + + sass-embedded-android-x64@1.99.0: + optional: true + + sass-embedded-darwin-arm64@1.99.0: + optional: true + + sass-embedded-darwin-x64@1.99.0: + optional: true + + sass-embedded-linux-arm64@1.99.0: + optional: true + + sass-embedded-linux-arm@1.99.0: + optional: true + + sass-embedded-linux-musl-arm64@1.99.0: + optional: true + + sass-embedded-linux-musl-arm@1.99.0: + optional: true + + sass-embedded-linux-musl-riscv64@1.99.0: + optional: true + + sass-embedded-linux-musl-x64@1.99.0: + optional: true + + sass-embedded-linux-riscv64@1.99.0: + optional: true + + sass-embedded-linux-x64@1.99.0: + optional: true + + sass-embedded-unknown-all@1.99.0: + dependencies: + sass: 1.99.0 + optional: true + + sass-embedded-win32-arm64@1.99.0: + optional: true + + sass-embedded-win32-x64@1.99.0: + optional: true + + sass-embedded@1.99.0: + dependencies: + '@bufbuild/protobuf': 2.12.0 + colorjs.io: 0.5.2 + immutable: 5.1.5 + rxjs: 7.8.2 + supports-color: 8.1.1 + sync-child-process: 1.0.2 + varint: 6.0.0 + optionalDependencies: + sass-embedded-all-unknown: 1.99.0 + sass-embedded-android-arm: 1.99.0 + sass-embedded-android-arm64: 1.99.0 + sass-embedded-android-riscv64: 1.99.0 + sass-embedded-android-x64: 1.99.0 + sass-embedded-darwin-arm64: 1.99.0 + sass-embedded-darwin-x64: 1.99.0 + sass-embedded-linux-arm: 1.99.0 + sass-embedded-linux-arm64: 1.99.0 + sass-embedded-linux-musl-arm: 1.99.0 + sass-embedded-linux-musl-arm64: 1.99.0 + sass-embedded-linux-musl-riscv64: 1.99.0 + sass-embedded-linux-musl-x64: 1.99.0 + sass-embedded-linux-riscv64: 1.99.0 + sass-embedded-linux-x64: 1.99.0 + sass-embedded-unknown-all: 1.99.0 + sass-embedded-win32-arm64: 1.99.0 + sass-embedded-win32-x64: 1.99.0 + sass@1.99.0: dependencies: chokidar: 4.0.3 @@ -6303,6 +6592,12 @@ snapshots: transitivePeerDependencies: - supports-color + solid-transition-group@0.3.0(solid-js@1.9.11): + dependencies: + '@solid-primitives/refs': 1.1.3(solid-js@1.9.11) + '@solid-primitives/transition-group': 1.1.2(solid-js@1.9.11) + solid-js: 1.9.11 + solid-use@0.9.1(solid-js@1.9.11): dependencies: solid-js: 1.9.11 @@ -6389,6 +6684,10 @@ snapshots: supports-color@10.2.2: {} + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} svgo@4.0.1: @@ -6401,6 +6700,12 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + sync-child-process@1.0.2: + dependencies: + sync-message-port: 1.2.0 + + sync-message-port@1.2.0: {} + system-architecture@0.1.0: {} tagged-tag@1.0.0: {} @@ -6468,6 +6773,8 @@ snapshots: trim-lines@3.0.1: {} + tslib@2.8.1: {} + type-fest@5.4.4: dependencies: tagged-tag: 1.0.0 @@ -6598,6 +6905,8 @@ snapshots: util-deprecate@1.0.2: {} + varint@6.0.0: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -6608,7 +6917,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)): + vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)): dependencies: '@babel/core': 7.29.0 '@types/babel__core': 7.20.5 @@ -6616,12 +6925,12 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.11 solid-refresh: 0.6.3(solid-js@1.9.11) - vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0) - vitefu: 1.1.2(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0) + vitefu: 1.1.2(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)) transitivePeerDependencies: - supports-color - vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0): + vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -6634,11 +6943,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 sass: 1.99.0 + sass-embedded: 1.99.0 terser: 5.46.0 - vitefu@1.1.2(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)): + vitefu@1.1.2(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)): optionalDependencies: - vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0) webidl-conversions@3.0.1: {} diff --git a/Frontend/public/fonts/CaveatBrush/CaveatBrush-Regular.woff2 b/Frontend/public/fonts/CaveatBrush/CaveatBrush-Regular.woff2 new file mode 100644 index 0000000..d5e738d Binary files /dev/null and b/Frontend/public/fonts/CaveatBrush/CaveatBrush-Regular.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-Black.woff2 b/Frontend/public/fonts/Poppins/Poppins-Black.woff2 new file mode 100644 index 0000000..2dfde80 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-Black.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-BlackItalic.woff2 b/Frontend/public/fonts/Poppins/Poppins-BlackItalic.woff2 new file mode 100644 index 0000000..c53e44b Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-BlackItalic.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-Bold.woff2 b/Frontend/public/fonts/Poppins/Poppins-Bold.woff2 new file mode 100644 index 0000000..13e0e28 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-Bold.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-BoldItalic.woff2 b/Frontend/public/fonts/Poppins/Poppins-BoldItalic.woff2 new file mode 100644 index 0000000..9787af0 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-BoldItalic.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-ExtraBold.woff2 b/Frontend/public/fonts/Poppins/Poppins-ExtraBold.woff2 new file mode 100644 index 0000000..ad86d02 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-ExtraBold.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-ExtraBoldItalic.woff2 b/Frontend/public/fonts/Poppins/Poppins-ExtraBoldItalic.woff2 new file mode 100644 index 0000000..a07bab0 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-ExtraBoldItalic.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-ExtraLight.woff2 b/Frontend/public/fonts/Poppins/Poppins-ExtraLight.woff2 new file mode 100644 index 0000000..89a440b Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-ExtraLight.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-ExtraLightItalic.woff2 b/Frontend/public/fonts/Poppins/Poppins-ExtraLightItalic.woff2 new file mode 100644 index 0000000..8b7c5ef Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-ExtraLightItalic.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-Italic.woff2 b/Frontend/public/fonts/Poppins/Poppins-Italic.woff2 new file mode 100644 index 0000000..0f4fa9b Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-Italic.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-Light.woff2 b/Frontend/public/fonts/Poppins/Poppins-Light.woff2 new file mode 100644 index 0000000..7eba2c4 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-Light.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-LightItalic.woff2 b/Frontend/public/fonts/Poppins/Poppins-LightItalic.woff2 new file mode 100644 index 0000000..5a891d4 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-LightItalic.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-Medium.woff2 b/Frontend/public/fonts/Poppins/Poppins-Medium.woff2 new file mode 100644 index 0000000..810ba18 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-Medium.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-MediumItalic.woff2 b/Frontend/public/fonts/Poppins/Poppins-MediumItalic.woff2 new file mode 100644 index 0000000..df0fd40 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-MediumItalic.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-Regular.woff2 b/Frontend/public/fonts/Poppins/Poppins-Regular.woff2 new file mode 100644 index 0000000..964d6d2 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-Regular.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-SemiBold.woff2 b/Frontend/public/fonts/Poppins/Poppins-SemiBold.woff2 new file mode 100644 index 0000000..9e4d0c0 Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-SemiBold.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-SemiBoldItalic.woff2 b/Frontend/public/fonts/Poppins/Poppins-SemiBoldItalic.woff2 new file mode 100644 index 0000000..acc345f Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-SemiBoldItalic.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-Thin.woff2 b/Frontend/public/fonts/Poppins/Poppins-Thin.woff2 new file mode 100644 index 0000000..f98239a Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-Thin.woff2 differ diff --git a/Frontend/public/fonts/Poppins/Poppins-ThinItalic.woff2 b/Frontend/public/fonts/Poppins/Poppins-ThinItalic.woff2 new file mode 100644 index 0000000..86ee4fd Binary files /dev/null and b/Frontend/public/fonts/Poppins/Poppins-ThinItalic.woff2 differ diff --git a/Frontend/src/app.tsx b/Frontend/src/app.tsx index 05c4180..4b3a49a 100644 --- a/Frontend/src/app.tsx +++ b/Frontend/src/app.tsx @@ -1,13 +1,42 @@ // Path: Frontend/src/app.tsx -import { createSignal } from "solid-js"; +import { Router, useLocation } from "@solidjs/router"; +import { FileRoutes } from "@solidjs/start/router"; +import { Suspense, Show, type ParentComponent } from "solid-js"; +import { Transition } from "solid-transition-group"; +import { ThemeProvider } from "./context/theme/context"; +import "./styles/main.scss"; -export default function App() { - const [count, setCount] = createSignal(0); +const AppRoot: ParentComponent = (props) => { + const location = useLocation(); + const isViewportLockedRoute = () => location.pathname === "/" || location.pathname.startsWith("/auth/"); return ( -
-

AI

-
+ +
+ + + {(path) => ( +
+ {props.children} +
+ )} +
+
+
+
+ ); +}; + +export default function App() { + return ( + + + + + ); } diff --git a/Frontend/src/components/assignment/assignment-header.module.scss b/Frontend/src/components/assignment/assignment-header.module.scss new file mode 100644 index 0000000..7cb3a76 --- /dev/null +++ b/Frontend/src/components/assignment/assignment-header.module.scss @@ -0,0 +1,60 @@ +.headerCard { + display: grid; + gap: 1.1rem; + padding: 1.5rem; + border-radius: 1.5rem; + background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end)); + color: var(--text-on-accent); + border: 1px solid var(--border-overlay); + box-shadow: var(--shadow-elevated); +} + +.headerTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.backLink, +.statusPill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem 0.85rem; + border-radius: 9999px; + background: var(--surface-overlay-soft); + border: 1px solid var(--border-overlay); + color: var(--text-on-accent); + font-size: 0.88rem; + font-weight: 500; + text-decoration: none; +} + +.copy { + display: grid; + gap: 0.5rem; + + h1 { + font-size: clamp(2rem, 1.55rem + 1.35vw, 3rem); + line-height: 0.98; + letter-spacing: -0.04em; + } + + p { + max-width: 55ch; + color: var(--text-on-accent); + } +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.78rem; + color: var(--text-on-accent-muted) !important; +} + +.description { + color: var(--text-on-accent-muted) !important; + font-size: 0.96rem; +} diff --git a/Frontend/src/components/assignment/assignment-header.tsx b/Frontend/src/components/assignment/assignment-header.tsx new file mode 100644 index 0000000..ff39c41 --- /dev/null +++ b/Frontend/src/components/assignment/assignment-header.tsx @@ -0,0 +1,30 @@ +import type { Component } from "solid-js"; +import { A } from "@solidjs/router"; +import type { AssignmentPageData } from "./assignment.data"; +import styles from "./assignment-header.module.scss"; + +type Props = { + data: AssignmentPageData; +}; + +const AssignmentHeader: Component = (props) => { + return ( +
+
+ + Back to dashboard + + {props.data.statusLabel} +
+ +
+

{props.data.classroomName}

+

{props.data.title}

+

{props.data.headline}

+

{props.data.description}

+
+
+ ); +}; + +export default AssignmentHeader; diff --git a/Frontend/src/components/assignment/assignment-overview.module.scss b/Frontend/src/components/assignment/assignment-overview.module.scss new file mode 100644 index 0000000..b5f464d --- /dev/null +++ b/Frontend/src/components/assignment/assignment-overview.module.scss @@ -0,0 +1,85 @@ +.stack { + display: grid; + gap: 1rem; +} + +.panel { + display: grid; + gap: 1rem; + padding: 1.2rem; + border-radius: 1.35rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); +} + +.cardHeader { + display: grid; + gap: 0.2rem; + + h2 { + font-size: 1.1rem; + line-height: 1.1; + letter-spacing: -0.03em; + } + + p { + color: var(--text-muted); + font-size: 0.9rem; + } +} + +.statGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.statCard { + display: grid; + gap: 0.25rem; + padding: 0.9rem; + border-radius: 1rem; + background: var(--surface-panel-strong); + border: 1px solid var(--border-divider); + + span { + color: var(--text-muted); + font-size: 0.82rem; + } + + strong { + font-size: 1.2rem; + line-height: 1; + } +} + +.coachCopy { + color: var(--text-muted); + font-size: 0.94rem; +} + +.tipList { + display: grid; + gap: 0.55rem; + padding-left: 1rem; + color: var(--text); + + li { + font-size: 0.92rem; + color: var(--text-muted); + } +} + +.primaryButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.9rem 1rem; + border-radius: 1rem; + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + font-weight: 600; + box-shadow: var(--action-primary-shadow); + text-decoration: none; +} diff --git a/Frontend/src/components/assignment/assignment-overview.tsx b/Frontend/src/components/assignment/assignment-overview.tsx new file mode 100644 index 0000000..7fd1c32 --- /dev/null +++ b/Frontend/src/components/assignment/assignment-overview.tsx @@ -0,0 +1,52 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { A } from "@solidjs/router"; +import type { AssignmentPageData } from "./assignment.data"; +import styles from "./assignment-overview.module.scss"; + +type Props = { + data: AssignmentPageData; +}; + +const AssignmentOverview: Component = (props) => { + return ( +
+
+
+

Assignment overview

+

{props.data.studentName}

+
+ +
+ + {(stat) => ( +
+ {stat.label} + {stat.value} +
+ )} +
+
+
+ +
+
+

{props.data.coachCard.title}

+

{props.data.tutorName}

+
+ +

{props.data.coachCard.description}

+ +
    + {(item) =>
  • {item}
  • }
    +
+ + + {props.data.primaryAction} + +
+
+ ); +}; + +export default AssignmentOverview; diff --git a/Frontend/src/components/assignment/assignment-question-list.module.scss b/Frontend/src/components/assignment/assignment-question-list.module.scss new file mode 100644 index 0000000..04de8bc --- /dev/null +++ b/Frontend/src/components/assignment/assignment-question-list.module.scss @@ -0,0 +1,123 @@ +.section { + display: grid; + gap: 1rem; +} + +.header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + + h2 { + font-size: 1.35rem; + line-height: 1.08; + letter-spacing: -0.03em; + } + + p { + color: var(--text-muted); + font-size: 0.9rem; + } +} + +.list { + display: grid; + gap: 0.9rem; +} + +.card { + display: grid; + gap: 0.85rem; + padding: 1.2rem; + border-radius: 1.35rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); +} + +.cardTop { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; + + h3 { + font-size: 1.03rem; + line-height: 1.35; + font-weight: 500; + } +} + +.order { + margin-bottom: 0.3rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.78rem; + color: var(--text-muted); +} + +.statusPill { + padding: 0.45rem 0.75rem; + border-radius: 9999px; + font-size: 0.82rem; + font-weight: 600; + white-space: nowrap; +} + +.success { + background: var(--surface-success); + color: var(--success); +} + +.warning { + background: var(--surface-warning-emphasis); + color: var(--text-warning-strong); +} + +.muted { + background: var(--surface-soft); + color: var(--text-muted); +} + +.metaRow { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + span { + padding: 0.35rem 0.6rem; + border-radius: 9999px; + background: var(--surface-panel-strong); + border: 1px solid var(--border-divider); + font-size: 0.82rem; + color: var(--text-muted); + } +} + +.responseBlock, +.answerKey { + display: grid; + gap: 0.25rem; + padding: 0.95rem 1rem; + border-radius: 1rem; + background: var(--surface-panel-strong); + border: 1px solid var(--border-divider); + + p { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + } + + strong { + font-size: 0.98rem; + font-weight: 600; + } + + span { + font-size: 0.9rem; + color: var(--text-muted); + } +} diff --git a/Frontend/src/components/assignment/assignment-question-list.tsx b/Frontend/src/components/assignment/assignment-question-list.tsx new file mode 100644 index 0000000..7538126 --- /dev/null +++ b/Frontend/src/components/assignment/assignment-question-list.tsx @@ -0,0 +1,62 @@ +import type { Component } from "solid-js"; +import { For, Show } from "solid-js"; +import type { AssignmentPageData } from "./assignment.data"; +import styles from "./assignment-question-list.module.scss"; + +type Props = { + data: AssignmentPageData; +}; + +const AssignmentQuestionList: Component = (props) => { + return ( +
+
+

Sample questions

+

{props.data.questions.length} loaded from the mock dataset

+
+ +
+ + {(question) => ( +
+
+
+

Question {question.order}

+

{question.prompt}

+
+ {question.statusLabel} +
+ +
+ {question.topic} + + {question.subTopic} + + {question.difficulty} + {question.marks} mark + + {question.solveModeLabel} + +
+ +
+

{question.responseLabel}

+ {question.responseValue} + {question.feedback} +
+ + +
+

Answer key

+ {question.correctAnswer} +
+
+
+ )} +
+
+
+ ); +}; + +export default AssignmentQuestionList; diff --git a/Frontend/src/components/assignment/assignment-tabs.tsx b/Frontend/src/components/assignment/assignment-tabs.tsx new file mode 100644 index 0000000..03db768 --- /dev/null +++ b/Frontend/src/components/assignment/assignment-tabs.tsx @@ -0,0 +1,27 @@ +import { A, useLocation, useParams } from "@solidjs/router"; +import type { Component } from "solid-js"; +import styles from "../../routes/assignment/assignment-page.module.scss"; + +const AssignmentTabs: Component = () => { + const params = useParams(); + const location = useLocation(); + + const reviewHref = () => `/assignment/${params.id}`; + const workHref = () => `/assignment/${params.id}/work`; + const isWork = () => location.pathname === workHref(); + + return ( + + ); +}; + +export default AssignmentTabs; diff --git a/Frontend/src/components/assignment/assignment.data.ts b/Frontend/src/components/assignment/assignment.data.ts new file mode 100644 index 0000000..d371972 --- /dev/null +++ b/Frontend/src/components/assignment/assignment.data.ts @@ -0,0 +1,251 @@ +import rawAssignments from "../../../../Mock-Data/assignments.json"; +import rawAssignmentAssignees from "../../../../Mock-Data/assignment_assignees.json"; +import rawAssignmentQuestions from "../../../../Mock-Data/assignment_questions.json"; +import rawQuestionBank from "../../../../Mock-Data/question_bank.json"; +import rawStudentAnswers from "../../../../Mock-Data/student_answers.json"; +import rawStudents from "../../../../Mock-Data/students.json"; +import rawClassroom from "../../../../Mock-Data/classroom.json"; + +type Assignment = { + id: number; + name: string; + topic: string; + due_date: number; + status: "DRAFT" | "PUBLISHED" | "CLOSED"; + maximum_marks: number; +}; + +type AssignmentAssignee = { + id: number; + assignment_id: number; + student_id: number; + status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED"; + total_marks: number; + started_at: number | null; + submitted_at: number | null; +}; + +type AssignmentQuestion = { + id: number; + assignment_id: number; + question_bank_id: number; + question_order: number; + maximum_marks: number; +}; + +type QuestionBankItem = { + id: number; + topic: string; + sub_topic: string | null; + difficulty: "EASY" | "MEDIUM" | "HARD"; + question_text: string; + correct_answer: string; + step_by_step_solution: string | null; +}; + +type StudentAnswer = { + assignee_id: number; + assignment_question_id: number; + extracted_answer: string; + ai_reasoning: string; + _solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; + _is_correct: boolean; + _time_on_task_seconds: number; +}; + +type Student = { + id: number; + fullname: string; + _persona: string; +}; + +type ClassroomFile = { + classroom: { + name: string; + target_level: number; + }; + tutor: { + fullname: string; + }; +}; + +export type AssignmentPageData = { + id: number; + title: string; + topic: string; + status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED"; + statusLabel: string; + dueLabel: string; + studentName: string; + classroomName: string; + tutorName: string; + headline: string; + description: string; + primaryAction: string; + primaryHref: string; + stats: Array<{ label: string; value: string }>; + coachCard: { + title: string; + description: string; + items: string[]; + }; + questions: Array<{ + id: number; + order: number; + prompt: string; + topic: string; + subTopic: string | null; + difficulty: "EASY" | "MEDIUM" | "HARD"; + marks: number; + statusLabel: string; + statusTone: "success" | "warning" | "muted"; + responseLabel: string; + responseValue: string; + feedback: string; + solveModeLabel?: string; + initialAnswer?: string; + initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; + showAnswerKey: boolean; + correctAnswer: string; + }>; +}; + +const assignments = rawAssignments as Assignment[]; +const assignmentAssignees = rawAssignmentAssignees as AssignmentAssignee[]; +const assignmentQuestions = rawAssignmentQuestions as AssignmentQuestion[]; +const questionBank = rawQuestionBank as QuestionBankItem[]; +const studentAnswers = rawStudentAnswers as StudentAnswer[]; +const students = rawStudents as Student[]; +const classroomFile = rawClassroom as ClassroomFile; + +const defaultStudentId = 201; + +const assignmentById = new Map(assignments.map((entry) => [entry.id, entry])); +const questionById = new Map(questionBank.map((entry) => [entry.id, entry])); +const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0]; + +const formatDate = (timestamp: number) => + new Intl.DateTimeFormat("en-GB", { + weekday: "short", + month: "short", + day: "numeric", + }).format(new Date(timestamp)); + +const formatSolveMode = (value: StudentAnswer["_solve_mode"]) => { + switch (value) { + case "just_answer": + return "Just answer"; + case "step_by_step": + return "Step by step"; + case "solve_together": + return "Solve together"; + case "handwritten": + return "Handwritten"; + } +}; + +const personaHint = (persona: string) => { + switch (persona) { + case "fraction_inversion": + return "Slow down on fraction rules and check each step before moving on."; + case "place_value_gaps": + return "Check place value carefully before you calculate the final answer."; + case "rushed_careless": + return "Pause before submitting so small slips do not cost easy marks."; + case "solve_together_dependent": + return "Try one independent attempt first, then ask for guided help if you need it."; + case "word_problem_weak": + return "Underline the key numbers and turn the sentence into a maths step first."; + default: + return "Work through one question at a time and keep your method tidy."; + } +}; + +export const getAssignmentPageData = (assignmentId: number): AssignmentPageData | null => { + const assignment = assignmentById.get(assignmentId); + if (!assignment) return null; + + const assignee = assignmentAssignees.find((entry) => entry.assignment_id === assignment.id && entry.student_id === student.id); + if (!assignee) return null; + + const assignmentQuestionRows = assignmentQuestions + .filter((entry) => entry.assignment_id === assignment.id) + .sort((left, right) => left.question_order - right.question_order) + .map((entry) => { + const question = questionById.get(entry.question_bank_id); + if (!question) throw new Error(`Missing question bank record ${entry.question_bank_id}`); + + const answer = studentAnswers.find((studentAnswer) => studentAnswer.assignee_id === assignee.id && studentAnswer.assignment_question_id === entry.id); + + return { entry, question, answer }; + }); + + const answeredCount = assignmentQuestionRows.filter((row) => !!row.answer).length; + const correctCount = assignmentQuestionRows.filter((row) => row.answer?._is_correct).length; + const accuracy = answeredCount > 0 ? Math.round((correctCount / answeredCount) * 100) : 0; + + const statusLabel = assignee.status === "SUBMITTED" ? "Submitted" : assignee.status === "IN_PROGRESS" ? "In progress" : "Not started"; + const primaryAction = assignee.status === "SUBMITTED" ? "Review assignment" : assignee.status === "IN_PROGRESS" ? "Continue assignment" : "Start assignment"; + + const questions = assignmentQuestionRows.map(({ entry, question, answer }) => ({ + id: entry.id, + order: entry.question_order, + prompt: question.question_text, + topic: question.topic, + subTopic: question.sub_topic, + difficulty: question.difficulty, + marks: entry.maximum_marks, + statusLabel: answer ? (answer._is_correct ? "Correct" : "Needs review") : "Not answered", + statusTone: answer ? (answer._is_correct ? "success" : "warning") : "muted", + responseLabel: answer ? "Your latest answer" : "Status", + responseValue: answer ? answer.extracted_answer : "No attempt yet", + feedback: answer ? answer.ai_reasoning : "This sample question is ready when you are.", + solveModeLabel: answer ? formatSolveMode(answer._solve_mode) : undefined, + initialAnswer: answer?.extracted_answer, + initialSolveMode: answer?._solve_mode, + showAnswerKey: assignee.status === "SUBMITTED", + correctAnswer: question.correct_answer, + })); + + return { + id: assignment.id, + title: assignment.name, + topic: assignment.topic, + status: assignee.status, + statusLabel, + dueLabel: formatDate(assignment.due_date), + studentName: student.fullname, + classroomName: classroomFile.classroom.name, + tutorName: classroomFile.tutor.fullname, + headline: + assignee.status === "SUBMITTED" + ? `Review how you did in ${assignment.topic}` + : assignee.status === "IN_PROGRESS" + ? `Keep going — you are already part way through` + : `Start this assignment with a steady first pass`, + description: + assignee.status === "SUBMITTED" + ? `You scored ${assignee.total_marks}/${assignment.maximum_marks}. Use the sample questions below to revisit what felt easy and what still needs another try.` + : assignee.status === "IN_PROGRESS" + ? `You have answered ${answeredCount} of ${assignmentQuestionRows.length} questions. Finish the rest while the topic is still fresh.` + : `This assignment has ${assignmentQuestionRows.length} sample questions. Start with the easier wins, then work up to the harder ones.`, + primaryAction, + primaryHref: `/assignment/${assignment.id}/work`, + stats: [ + { label: "Status", value: statusLabel }, + { label: "Due", value: formatDate(assignment.due_date) }, + { label: "Questions", value: `${assignmentQuestionRows.length}` }, + { label: assignee.status === "SUBMITTED" ? "Score" : "Answered", value: assignee.status === "SUBMITTED" ? `${assignee.total_marks}/${assignment.maximum_marks}` : `${answeredCount}/${assignmentQuestionRows.length}` }, + ], + coachCard: { + title: "How to approach this one", + description: personaHint(student._persona), + items: [ + `${assignment.topic} focus`, + `${accuracy}% accuracy so far`, + `${correctCount} correct answers logged`, + ], + }, + questions, + }; +}; diff --git a/Frontend/src/components/dashboard/dashboard-activity.module.scss b/Frontend/src/components/dashboard/dashboard-activity.module.scss new file mode 100644 index 0000000..dd082ef --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-activity.module.scss @@ -0,0 +1,170 @@ +.section { + display: grid; + gap: 1.05rem; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + + h2 { + font-size: 1.25rem; + line-height: 1.1; + letter-spacing: -0.03em; + } + + a { + color: var(--primary); + font-weight: 500; + font-size: 0.9rem; + } +} + +@media (max-width: 519px) { + .header a { + font-size: 0.85rem; + } +} + +.progressCard, +.highlightCard { + border-radius: 1.35rem; + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); +} + +.progressCard { + display: grid; + gap: 1rem; + padding: 1rem; + background: var(--surface-panel); + + @media (min-width: 640px) { + padding: 1.15rem; + } + + @media (min-width: 960px) { + padding: 1.35rem; + } +} + +.progressHeader { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + + p { + color: var(--text-muted); + font-size: 0.92rem; + } + + > div:first-child { + min-width: 0; + flex: 1 1 13rem; + } +} + +.progressHeader h3 { + font-size: 1.15rem; + line-height: 1.1; +} + +.progressBadge { + padding: 0.45rem 0.7rem; + border-radius: 9999px; + background: var(--surface-info); + color: var(--info); + font-size: 0.85rem; + font-weight: 600; + white-space: nowrap; + align-self: start; +} + +.chart { + width: 100%; + height: auto; +} + +.axis { + stroke: var(--border-divider); + stroke-width: 1; +} + +.area { + fill: url(#progress-fill); + stroke: none; +} + +.line { + fill: none; + stroke: var(--blue-400); + stroke-width: 4; + stroke-linecap: round; + stroke-linejoin: round; +} + +.monthRow { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 0.4rem; + color: var(--text-muted); + font-size: 0.85rem; + text-align: center; +} + +@media (max-width: 519px) { + .monthRow { + font-size: 0.72rem; + gap: 0.25rem; + } +} + +.highlightGrid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.85rem; + + @media (min-width: 560px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.highlightCard { + display: grid; + gap: 0.35rem; + padding: 1rem; + color: var(--text); + + strong { + font-size: 2rem; + font-size: clamp(1.55rem, 1.25rem + 1vw, 2rem); + line-height: 1; + font-weight: 700; + } + + h3 { + font-size: 1rem; + font-weight: 600; + } + + p { + font-size: 0.9rem; + color: inherit; + opacity: 0.84; + } +} + +.yellow { + background: var(--surface-warning-emphasis); + color: var(--text-warning-strong); +} + +.pink { + background: var(--surface-danger-emphasis); + color: var(--text-danger-strong); +} diff --git a/Frontend/src/components/dashboard/dashboard-activity.tsx b/Frontend/src/components/dashboard/dashboard-activity.tsx new file mode 100644 index 0000000..42a24d8 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-activity.tsx @@ -0,0 +1,61 @@ +// Path: Frontend/src/components/dashboard/dashboard-activity.tsx + +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { A } from "@solidjs/router"; +import styles from "./dashboard-activity.module.scss"; +import { activitySummary, highlightCards, progressLabels, progressPoints } from "./dashboard.data"; + +const points = progressPoints.map((value, index) => `${index * 54},${120 - value}`).join(" "); + +const DashboardActivity: Component = () => { + return ( +
+
+

Your progress

+ Open progress +
+ +
+
+
+

{activitySummary.title}

+

{activitySummary.note}

+
+
{activitySummary.badge}
+
+ + + + + + + + + + + + + + +
+ {(label) => {label}} +
+
+ +
+ + {(card) => ( +
+ {card.value} +

{card.label}

+

{card.note}

+
+ )} +
+
+
+ ); +}; + +export default DashboardActivity; diff --git a/Frontend/src/components/dashboard/dashboard-assignments-focus.module.scss b/Frontend/src/components/dashboard/dashboard-assignments-focus.module.scss new file mode 100644 index 0000000..7ef663e --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-assignments-focus.module.scss @@ -0,0 +1,245 @@ +.section { + display: grid; + gap: 1rem; + + @media (min-width: 960px) { + gap: 1.25rem; + } +} + +.heroCard { + display: grid; + gap: 1rem; + padding: 1.15rem; + border-radius: 1.5rem; + background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong)); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-soft); + + @media (min-width: 880px) { + grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr); + align-items: center; + padding: 1.35rem; + } +} + +.heroCopy { + display: grid; + gap: 0.55rem; + + h1 { + font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem); + line-height: 1; + letter-spacing: -0.04em; + } + + p:last-child { + color: var(--text-muted); + font-size: 0.96rem; + max-width: 52ch; + } +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.75rem; + color: var(--text-muted); +} + +.statGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + + @media (min-width: 520px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + @media (min-width: 880px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.statCard { + display: grid; + gap: 0.2rem; + padding: 0.95rem; + border-radius: 1.15rem; + background: var(--surface-soft); + border: 1px solid var(--border-soft); + + strong { + font-size: 1.25rem; + line-height: 1; + } + + span { + font-size: 0.84rem; + color: var(--text-muted); + } +} + +.groupList { + display: grid; + gap: 1rem; +} + +.group { + display: grid; + gap: 0.85rem; +} + +.groupHeader { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + + h2 { + font-size: 1.2rem; + line-height: 1.1; + letter-spacing: -0.03em; + } + + p { + margin-top: 0.2rem; + font-size: 0.92rem; + color: var(--text-muted); + } +} + +.groupCount { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.1rem; + height: 2.1rem; + padding: 0 0.7rem; + border-radius: 9999px; + background: var(--surface-info-emphasis); + color: var(--text-info-strong); + font-weight: 600; + font-size: 0.88rem; +} + +.cardGrid { + display: grid; + gap: 0.85rem; + + @media (min-width: 820px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.assignmentCard { + display: grid; + gap: 0.9rem; + padding: 1rem; + border-radius: 1.3rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); +} + +.cardTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.statusChip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.42rem 0.68rem; + border-radius: 9999px; + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; +} + +.blue { + background: var(--surface-info-emphasis); + color: var(--text-info-strong); +} + +.yellow { + background: var(--surface-warning-emphasis); + color: var(--text-warning-strong); +} + +.teal { + background: var(--surface-success-emphasis); + color: var(--text-success-strong); +} + +.progressText { + font-size: 0.82rem; + color: var(--text-muted); + white-space: nowrap; +} + +.cardBody { + display: grid; + gap: 0.28rem; + + h3 { + font-size: 1rem; + font-weight: 500; + } + + p { + font-size: 0.92rem; + color: var(--text-muted); + } + + small { + font-size: 0.82rem; + color: var(--text-muted); + } +} + +.cardActions { + display: grid; + gap: 0.65rem; + + @media (min-width: 520px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.primaryAction, +.secondaryAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.8rem 1rem; + border-radius: 9999px; + font-weight: 500; + text-decoration: none; + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; + + &:hover { + text-decoration: none; + transform: translateY(-1px); + } +} + +.primaryAction { + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + box-shadow: var(--action-primary-shadow); +} + +.secondaryAction { + background: var(--surface-soft); + color: var(--text); + border: 1px solid var(--border-soft); +} diff --git a/Frontend/src/components/dashboard/dashboard-assignments-focus.tsx b/Frontend/src/components/dashboard/dashboard-assignments-focus.tsx new file mode 100644 index 0000000..944b547 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-assignments-focus.tsx @@ -0,0 +1,76 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { A } from "@solidjs/router"; +import { assignmentFocusGroups, assignmentFocusStats } from "./dashboard.data"; +import styles from "./dashboard-assignments-focus.module.scss"; + +const DashboardAssignmentsFocus: Component = () => { + return ( +
+
+
+

Assignments

+

Your assignment hub

+

Stay in dashboard mode to see what is live, what needs finishing, and what is ready for review.

+
+ +
+ + {(stat) => ( +
+ {stat.value} + {stat.label} +
+ )} +
+
+
+ +
+ + {(group) => ( +
+
+
+

{group.title}

+

{group.description}

+
+ {group.items.length} +
+ +
+ + {(item) => ( + + )} + +
+
+ )} +
+
+
+ ); +}; + +export default DashboardAssignmentsFocus; diff --git a/Frontend/src/components/dashboard/dashboard-courses.module.scss b/Frontend/src/components/dashboard/dashboard-courses.module.scss new file mode 100644 index 0000000..2c8ed61 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-courses.module.scss @@ -0,0 +1,103 @@ +.section { + display: grid; + gap: 1.05rem; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + + h2 { + font-size: 1.25rem; + line-height: 1.1; + letter-spacing: -0.03em; + } + + a { + color: var(--primary); + font-weight: 500; + font-size: 0.9rem; + } +} + +.courseList { + display: grid; + gap: 0.85rem; +} + +.courseCard { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.85rem; + padding: 0.95rem; + border-radius: 1.25rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); + + @media (min-width: 720px) { + grid-template-columns: auto 1fr auto; + align-items: center; + } + + .ctaLink { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0.68rem 0.95rem; + border-radius: 9999px; + background: var(--surface-info); + color: var(--info); + font-weight: 500; + font-size: 0.9rem; + transition: + transform 0.2s ease, + background-color 0.2s ease; + text-decoration: none; + + &:hover { + transform: translateY(-1px); + background: color-mix(in srgb, var(--surface-info) 70%, var(--blue-100)); + text-decoration: none; + } + } +} + +@media (min-width: 720px) { + .courseCard .ctaLink { + width: auto; + } +} + +.badge { + width: 2.8rem; + height: 2.8rem; + display: grid; + place-items: center; + border-radius: 0.9rem; + font-weight: 600; +} + +.yellow { background: var(--yellow-200); color: var(--yellow-500); } +.pink { background: var(--red-100); color: var(--red-400); } +.teal { background: var(--teal-100); color: var(--teal-500); } +.blue { background: var(--blue-100); color: var(--blue-500); } + +.courseBody { + display: grid; + gap: 0.25rem; + + h3 { + font-size: 1rem; + font-weight: 500; + } + + p { + font-size: 0.9rem; + color: var(--text-muted); + } +} diff --git a/Frontend/src/components/dashboard/dashboard-courses.tsx b/Frontend/src/components/dashboard/dashboard-courses.tsx new file mode 100644 index 0000000..1d51b48 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-courses.tsx @@ -0,0 +1,35 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { A } from "@solidjs/router"; +import { assignmentCards } from "./dashboard.data"; +import styles from "./dashboard-courses.module.scss"; + +const DashboardCourses: Component = () => { + return ( +
+
+

Keep going

+ See all +
+ +
+ + {(assignment) => ( +
+
{assignment.title.split("—")[0].trim().replace("HW", "H")}
+
+

{assignment.title}

+

{assignment.lessons}

+
+ + {assignment.cta} + +
+ )} +
+
+
+ ); +}; + +export default DashboardCourses; diff --git a/Frontend/src/components/dashboard/dashboard-hero.module.scss b/Frontend/src/components/dashboard/dashboard-hero.module.scss new file mode 100644 index 0000000..842d3f1 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-hero.module.scss @@ -0,0 +1,261 @@ +.heroGrid { + display: grid; + gap: 1rem; + + @media (min-width: 960px) { + gap: 1.25rem; + } + + @media (min-width: 1120px) { + grid-template-columns: minmax(0, 1.6fr) minmax(18rem, 0.75fr); + } +} + +.heroCard, +.sideCard, +.statCard { + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-soft); +} + +.heroCard { + display: grid; + gap: 1.25rem; + padding: 1.15rem; + border-radius: 1.5rem; + background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end)); + color: var(--text-on-accent); + overflow: hidden; + + @media (min-width: 640px) { + padding: 1.4rem; + } + + @media (min-width: 960px) { + gap: 1.6rem; + padding: 1.75rem; + } + + @media (min-width: 900px) { + grid-template-columns: minmax(0, 1.2fr) minmax(14rem, 0.8fr); + align-items: center; + } +} + +.heroCopy { + display: grid; + gap: 0.95rem; +} + +.heroCopy h1 { + max-width: 9ch; + font-size: clamp(1.85rem, 1.5rem + 1.4vw, 3.5rem); + line-height: 0.99; + letter-spacing: -0.04em; +} + +.heroCopy p { + max-width: 40ch; + font-size: 0.94rem; + color: var(--text-on-accent-muted); + + @media (min-width: 640px) { + font-size: 1rem; + } +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-on-accent-muted); +} + +.metricRow { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 0.4rem; +} + +.metricPill { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.62rem 0.8rem; + border-radius: 9999px; + background: var(--surface-overlay-soft); + backdrop-filter: blur(8px); + border: 1px solid var(--border-overlay); + min-width: 0; + + p, + strong { + color: var(--text-on-accent); + } + + p { + font-size: 0.8rem; + } + + strong { + font-size: 0.85rem; + font-weight: 600; + } +} + +.metricIcon { + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + border-radius: 9999px; + font-weight: 700; + color: var(--text-accent-strong); +} + +.purple { + background: var(--surface-accent-emphasis); + color: var(--text-accent-strong); +} + +.yellow { + background: var(--surface-warning-emphasis); + color: var(--text-warning-strong); +} + +.blue { + background: var(--surface-info-emphasis); + color: var(--text-info-strong); +} + +.heroVisual { + position: relative; + min-height: 10.5rem; + display: grid; + place-items: center; + + @media (min-width: 640px) { + min-height: 12rem; + } + + @media (min-width: 960px) { + min-height: 13rem; + } +} + +.orbit { + position: absolute; + inset: 1rem; + border-radius: 50%; + border: 1px dashed var(--border-overlay); +} + +.visualCard { + position: absolute; + padding: 0.5rem 0.72rem; + border-radius: 9999px; + background: var(--surface-overlay-strong); + color: var(--text-on-accent); + font-weight: 600; + backdrop-filter: blur(10px); + font-size: 0.8rem; + + &:nth-child(2) { + top: 18%; + left: 18%; + } + + &:nth-child(3) { + right: 10%; + top: 42%; + } + + &:nth-child(4) { + bottom: 15%; + left: 35%; + } +} + +.sideCard { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: 1.5rem; + background: var(--surface-panel); + + @media (min-width: 640px) { + padding: 1.15rem; + } + + @media (min-width: 960px) { + gap: 1rem; + padding: 1.35rem; + } +} + +.sideCard h2 { + max-width: 12ch; + font-size: clamp(1.35rem, 1.12rem + 0.55vw, 1.8rem); + line-height: 1.08; + letter-spacing: -0.03em; +} + +.sideCard p { + color: var(--text-muted); + font-size: 0.92rem; +} + +.sideCardAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.82rem 0.95rem; + border: none; + border-radius: 1rem; + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + font-weight: 600; + box-shadow: var(--action-primary-shadow); + cursor: pointer; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: var(--action-primary-shadow-hover); + text-decoration: none; + } +} + +.quickStats { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.7rem; + + @media (min-width: 480px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.statCard { + display: grid; + gap: 0.45rem; + padding: 0.9rem; + border-radius: 1.2rem; + background: var(--surface-panel-strong); + border: 1px solid var(--border-soft); + + p { + font-size: 0.85rem; + color: var(--text-muted); + } + + strong { + font-size: 1.55rem; + line-height: 1; + font-weight: 600; + } +} diff --git a/Frontend/src/components/dashboard/dashboard-hero.tsx b/Frontend/src/components/dashboard/dashboard-hero.tsx new file mode 100644 index 0000000..490c1ce --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-hero.tsx @@ -0,0 +1,59 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { A } from "@solidjs/router"; +import { heroSideCard, heroSummary, quickStats, spotlightStats } from "./dashboard.data"; +import styles from "./dashboard-hero.module.scss"; + +const DashboardHero: Component = () => { + return ( +
+
+
+

{heroSummary.eyebrow}

+

{heroSummary.title}

+

{heroSummary.description}

+ +
+ + {(stat) => ( +
+ {stat.label.slice(0, 1)} +
+

{stat.label}

+ {stat.value} +
+
+ )} +
+
+
+ + +
+ +
+

{heroSideCard.title}

+

{heroSideCard.description}

+ + {heroSideCard.buttonLabel} + + +
+ + {(stat) => ( +
+

{stat.label}

+ {stat.value} +
+ )} +
+
+
+
+ ); +}; + +export default DashboardHero; diff --git a/Frontend/src/components/dashboard/dashboard-insights.module.scss b/Frontend/src/components/dashboard/dashboard-insights.module.scss new file mode 100644 index 0000000..4b7cb05 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-insights.module.scss @@ -0,0 +1,157 @@ +.grid { + display: grid; + gap: 1rem; + + @media (min-width: 960px) { + gap: 1.25rem; + } + + @media (min-width: 1120px) { + grid-template-columns: minmax(0, 1.3fr) minmax(16rem, 0.8fr) minmax(16rem, 0.9fr); + } +} + +.panel { + display: grid; + gap: 1rem; + padding: 1rem; + border-radius: 1.35rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); + + @media (min-width: 640px) { + padding: 1.15rem; + } + + @media (min-width: 960px) { + padding: 1.35rem; + } + + h2 { + font-size: 1.2rem; + line-height: 1.12; + letter-spacing: -0.03em; + } +} + +.barChart, +.usageList { + display: grid; + gap: 0.9rem; +} + +.barRow, +.usageItem { + display: grid; + gap: 0.45rem; +} + +.barMeta, +.usageMeta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + color: var(--text-muted); + flex-wrap: wrap; +} + +.barMeta { + justify-content: flex-start; + + span:last-child { + color: var(--text); + } +} + +.dot { + width: 0.75rem; + height: 0.75rem; + border-radius: 9999px; + display: inline-block; + margin-right: 0.55rem; +} + +.track { + height: 0.75rem; + border-radius: 9999px; + background: var(--surface-soft); + overflow: hidden; +} + +.fill { + height: 100%; + border-radius: inherit; +} + +.blue { background: var(--blue-400); } +.purple { background: var(--purple-400); } +.teal { background: var(--teal-400); } +.pink { background: var(--red-300); } +.yellow { background: var(--yellow-300); } + +.donutWrap { + display: grid; + place-items: center; + padding: 0.5rem 0; + position: relative; + min-height: 12rem; + + @media (min-width: 640px) { + padding: 1rem 0; + min-height: 15rem; + } +} + +.donut { + width: 9.5rem; + height: 9.5rem; + border-radius: 50%; + background: conic-gradient(var(--purple-400) 0 54%, var(--blue-400) 54% 74%, var(--surface-soft) 74% 100%); + position: relative; + + @media (min-width: 640px) { + width: 12rem; + height: 12rem; + } + + &::after { + content: ""; + position: absolute; + inset: 1.45rem; + border-radius: 50%; + background: var(--surface-panel-strong); + border: 1px solid var(--border-divider); + + @media (min-width: 640px) { + inset: 1.8rem; + } + } +} + +.donutLabel { + position: absolute; + display: grid; + place-items: center; + gap: 0.15rem; + + strong { + font-size: 1.6rem; + font-weight: 700; + + @media (min-width: 640px) { + font-size: 2rem; + } + } + + span { + color: var(--text-muted); + } +} + +.usageNote { + color: var(--info); + font-weight: 500; + font-size: 0.92rem; +} diff --git a/Frontend/src/components/dashboard/dashboard-insights.tsx b/Frontend/src/components/dashboard/dashboard-insights.tsx new file mode 100644 index 0000000..ed983f9 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-insights.tsx @@ -0,0 +1,62 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { overallPassRate, topicMasteryBars, solveModeUsage, usageSummary } from "./dashboard.data"; +import styles from "./dashboard-insights.module.scss"; + +const DashboardInsights: Component = () => { + return ( +
+
+

My topic mastery

+
+ + {(item) => ( +
+
+ + {item.label} +
+
+
+
+
+ )} +
+
+
+ +
+

My overall accuracy

+
+
+
+ {overallPassRate}% + Accuracy +
+
+
+ +
+

Solve mode usage

+

{usageSummary.note}

+
+ + {(item) => ( +
+
+ {item.label} + {item.value}% +
+
+
+
+
+ )} +
+
+
+
+ ); +}; + +export default DashboardInsights; diff --git a/Frontend/src/components/dashboard/dashboard-instructors.module.scss b/Frontend/src/components/dashboard/dashboard-instructors.module.scss new file mode 100644 index 0000000..227b570 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-instructors.module.scss @@ -0,0 +1,103 @@ +.section { + display: grid; + gap: 1.05rem; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + + h2 { + font-size: 1.25rem; + line-height: 1.1; + letter-spacing: -0.03em; + } + + a { + color: var(--primary); + font-weight: 500; + font-size: 0.9rem; + } +} + +.list { + display: grid; + gap: 0.85rem; +} + +.card { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.85rem; + padding: 0.95rem; + border-radius: 1.25rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); + + @media (min-width: 720px) { + grid-template-columns: auto 1fr auto; + align-items: center; + } + + .actionLink { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0.7rem 0.95rem; + border: none; + border-radius: 9999px; + background: var(--surface-soft); + color: var(--text-muted); + font-weight: 500; + font-size: 0.88rem; + cursor: pointer; + transition: + transform 0.2s ease, + background-color 0.2s ease, + color 0.2s ease; + + &:hover { + transform: translateY(-1px); + background: var(--surface-info); + color: var(--info); + text-decoration: none; + } + } +} + +@media (min-width: 720px) { + .card .actionLink { + width: auto; + } +} + +.avatar { + width: 3rem; + height: 3rem; + display: grid; + place-items: center; + border-radius: 9999px; + background: linear-gradient(135deg, var(--surface-accent-soft), var(--surface-info)); + color: var(--primary); + font-weight: 600; +} + +.copy { + display: grid; + gap: 0.2rem; + + h3 { + font-size: 0.98rem; + font-weight: 500; + } + + p { + font-size: 0.9rem; + color: var(--text-muted); + } +} diff --git a/Frontend/src/components/dashboard/dashboard-instructors.tsx b/Frontend/src/components/dashboard/dashboard-instructors.tsx new file mode 100644 index 0000000..36d90bf --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-instructors.tsx @@ -0,0 +1,37 @@ +// Path: Frontend/src/components/dashboard/dashboard-instructors.tsx + +import { A } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import styles from "./dashboard-instructors.module.scss"; +import { studentSupportList } from "./dashboard.data"; + +const DashboardInstructors: Component = () => { + return ( +
+
+

Try these next

+ View plan +
+ +
+ + {(student) => ( + + )} + +
+
+ ); +}; + +export default DashboardInstructors; diff --git a/Frontend/src/components/dashboard/dashboard-messages-focus.module.scss b/Frontend/src/components/dashboard/dashboard-messages-focus.module.scss new file mode 100644 index 0000000..781b17c --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-messages-focus.module.scss @@ -0,0 +1,188 @@ +.section { + display: grid; + gap: 1rem; + + @media (min-width: 960px) { + gap: 1.25rem; + } +} + +.heroCard { + display: grid; + gap: 1rem; + padding: 1.15rem; + border-radius: 1.5rem; + background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong)); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-soft); + + @media (min-width: 880px) { + grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr); + align-items: center; + padding: 1.35rem; + } +} + +.heroCopy { + display: grid; + gap: 0.55rem; + + h1 { + font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem); + line-height: 1; + letter-spacing: -0.04em; + } + + p:last-child { + color: var(--text-muted); + font-size: 0.96rem; + max-width: 58ch; + } +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.75rem; + color: var(--text-muted); +} + +.statGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + + @media (min-width: 520px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + @media (min-width: 880px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.statCard { + display: grid; + gap: 0.2rem; + padding: 0.95rem; + border-radius: 1.15rem; + background: var(--surface-soft); + border: 1px solid var(--border-soft); + + strong { + font-size: 1.25rem; + line-height: 1; + } + + span { + font-size: 0.84rem; + color: var(--text-muted); + } +} + +.threadList { + display: grid; + gap: 1rem; + + @media (min-width: 900px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.threadCard { + display: grid; + gap: 0.95rem; + padding: 1rem; + border-radius: 1.3rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); +} + +.threadTop { + display: flex; + justify-content: space-between; + align-items: start; + gap: 0.85rem; + flex-wrap: wrap; +} + +.senderWrap { + display: flex; + align-items: center; + gap: 0.8rem; + + h2 { + font-size: 1rem; + line-height: 1.1; + } + + p { + margin-top: 0.18rem; + font-size: 0.86rem; + color: var(--text-muted); + } +} + +.avatar { + width: 2.5rem; + height: 2.5rem; + display: grid; + place-items: center; + border-radius: 9999px; + background: var(--surface-accent-soft); + color: var(--primary); + font-weight: 600; + flex-shrink: 0; +} + +.threadMeta { + display: grid; + justify-items: end; + gap: 0.32rem; + font-size: 0.82rem; + color: var(--text-muted); +} + +.unread { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.32rem 0.62rem; + border-radius: 9999px; + background: var(--surface-info-emphasis); + color: var(--text-info-strong); + font-size: 0.74rem; + font-weight: 600; +} + +.preview { + font-size: 0.93rem; + color: var(--text-muted); +} + +.threadActions { + display: flex; + justify-content: flex-start; +} + +.primaryAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.8rem 1rem; + border-radius: 9999px; + font-weight: 500; + text-decoration: none; + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + box-shadow: var(--action-primary-shadow); + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + + &:hover { + text-decoration: none; + transform: translateY(-1px); + } +} diff --git a/Frontend/src/components/dashboard/dashboard-messages-focus.tsx b/Frontend/src/components/dashboard/dashboard-messages-focus.tsx new file mode 100644 index 0000000..e56b7c8 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-messages-focus.tsx @@ -0,0 +1,62 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { A } from "@solidjs/router"; +import { messageFocusStats, messageFocusThreads } from "./dashboard.data"; +import styles from "./dashboard-messages-focus.module.scss"; + +const DashboardMessagesFocus: Component = () => { + return ( +
+
+
+

Messages

+

Your message centre

+

Keep dashboard context while you check tutor guidance, assignment reminders, and quick study nudges that point you to the next useful action.

+
+ +
+ + {(stat) => ( +
+ {stat.value} + {stat.label} +
+ )} +
+
+
+ +
+ + {(thread) => ( +
+
+
+ {thread.initials} +
+

{thread.sender}

+

{thread.role}

+
+
+
+ {thread.timestamp} + {thread.unread && Unread} +
+
+ +

{thread.preview}

+ + +
+ )} +
+
+
+ ); +}; + +export default DashboardMessagesFocus; diff --git a/Frontend/src/components/dashboard/dashboard-practice-focus.module.scss b/Frontend/src/components/dashboard/dashboard-practice-focus.module.scss new file mode 100644 index 0000000..0fc943c --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-practice-focus.module.scss @@ -0,0 +1,185 @@ +.section { + display: grid; + gap: 1rem; + + @media (min-width: 960px) { + gap: 1.25rem; + } +} + +.heroCard { + display: grid; + gap: 1rem; + padding: 1.15rem; + border-radius: 1.5rem; + background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong)); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-soft); + + @media (min-width: 880px) { + grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr); + align-items: center; + padding: 1.35rem; + } +} + +.heroCopy { + display: grid; + gap: 0.55rem; + + h1 { + font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem); + line-height: 1; + letter-spacing: -0.04em; + } + + p:last-child { + color: var(--text-muted); + font-size: 0.96rem; + max-width: 58ch; + } +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.75rem; + color: var(--text-muted); +} + +.statGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + + @media (min-width: 520px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + @media (min-width: 880px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.statCard { + display: grid; + gap: 0.2rem; + padding: 0.95rem; + border-radius: 1.15rem; + background: var(--surface-soft); + border: 1px solid var(--border-soft); + + strong { + font-size: 1.25rem; + line-height: 1; + } + + span { + font-size: 0.84rem; + color: var(--text-muted); + } +} + +.cardGrid { + display: grid; + gap: 1rem; + + @media (min-width: 820px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.practiceCard { + display: grid; + gap: 0.95rem; + padding: 1rem; + border-radius: 1.3rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); +} + +.toneChip { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + max-width: 100%; + padding: 0.42rem 0.72rem; + border-radius: 9999px; + font-size: 0.76rem; + font-weight: 600; + line-height: 1.2; +} + +.yellow { + background: var(--surface-warning-emphasis); + color: var(--text-warning-strong); +} + +.blue { + background: var(--surface-info-emphasis); + color: var(--text-info-strong); +} + +.teal { + background: var(--surface-success-emphasis); + color: var(--text-success-strong); +} + +.cardBody { + display: grid; + gap: 0.38rem; + + h2 { + font-size: 1.05rem; + line-height: 1.12; + letter-spacing: -0.03em; + } + + p { + font-size: 0.92rem; + color: var(--text-muted); + } +} + +.cardActions { + display: grid; + gap: 0.65rem; + + @media (min-width: 520px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.primaryAction, +.secondaryAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.8rem 1rem; + border-radius: 9999px; + font-weight: 500; + text-decoration: none; + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; + + &:hover { + text-decoration: none; + transform: translateY(-1px); + } +} + +.primaryAction { + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + box-shadow: var(--action-primary-shadow); +} + +.secondaryAction { + background: var(--surface-soft); + color: var(--text); + border: 1px solid var(--border-soft); +} diff --git a/Frontend/src/components/dashboard/dashboard-practice-focus.tsx b/Frontend/src/components/dashboard/dashboard-practice-focus.tsx new file mode 100644 index 0000000..d2f82eb --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-practice-focus.tsx @@ -0,0 +1,56 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { A } from "@solidjs/router"; +import { practiceFocusCards, practiceFocusStats } from "./dashboard.data"; +import styles from "./dashboard-practice-focus.module.scss"; + +const DashboardPracticeFocus: Component = () => { + return ( +
+
+
+

Practice

+

Your practice space

+

Stay in dashboard mode to focus on the skill that needs the most attention, choose the right support mode, and jump into the next useful question set.

+
+ +
+ + {(stat) => ( +
+ {stat.value} + {stat.label} +
+ )} +
+
+
+ +
+ + {(card) => ( + + )} + +
+
+ ); +}; + +export default DashboardPracticeFocus; diff --git a/Frontend/src/components/dashboard/dashboard-progress-focus.module.scss b/Frontend/src/components/dashboard/dashboard-progress-focus.module.scss new file mode 100644 index 0000000..d6a8134 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-progress-focus.module.scss @@ -0,0 +1,90 @@ +.section { + display: grid; + gap: 1rem; + + @media (min-width: 960px) { + gap: 1.25rem; + } +} + +.heroCard { + display: grid; + gap: 1rem; + padding: 1.15rem; + border-radius: 1.5rem; + background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong)); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-soft); + + @media (min-width: 880px) { + grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr); + align-items: center; + padding: 1.35rem; + } +} + +.heroCopy { + display: grid; + gap: 0.55rem; + + h1 { + font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem); + line-height: 1; + letter-spacing: -0.04em; + } + + p:last-child { + color: var(--text-muted); + font-size: 0.96rem; + max-width: 58ch; + } +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.75rem; + color: var(--text-muted); +} + +.statGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + + @media (min-width: 520px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + @media (min-width: 880px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.statCard { + display: grid; + gap: 0.2rem; + padding: 0.95rem; + border-radius: 1.15rem; + background: var(--surface-soft); + border: 1px solid var(--border-soft); + + strong { + font-size: 1.25rem; + line-height: 1; + } + + span { + font-size: 0.84rem; + color: var(--text-muted); + } +} + +.contentStack { + display: grid; + gap: 1rem; + + @media (min-width: 960px) { + gap: 1.25rem; + } +} diff --git a/Frontend/src/components/dashboard/dashboard-progress-focus.tsx b/Frontend/src/components/dashboard/dashboard-progress-focus.tsx new file mode 100644 index 0000000..072caf8 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-progress-focus.tsx @@ -0,0 +1,38 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import DashboardActivity from "./dashboard-activity"; +import DashboardInsights from "./dashboard-insights"; +import { progressFocusStats } from "./dashboard.data"; +import styles from "./dashboard-progress-focus.module.scss"; + +const DashboardProgressFocus: Component = () => { + return ( +
+
+
+

Progress

+

Your learning progress

+

Stay inside the dashboard to review your results, track improvement, and spot where a small practice block could lift your score fastest.

+
+ +
+ + {(stat) => ( +
+ {stat.value} + {stat.label} +
+ )} +
+
+
+ +
+ + +
+
+ ); +}; + +export default DashboardProgressFocus; diff --git a/Frontend/src/components/dashboard/dashboard-settings-focus.module.scss b/Frontend/src/components/dashboard/dashboard-settings-focus.module.scss new file mode 100644 index 0000000..110542f --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-settings-focus.module.scss @@ -0,0 +1,223 @@ +.section { + display: grid; + gap: 1rem; + + @media (min-width: 960px) { + gap: 1.25rem; + } +} + +.heroCard { + display: grid; + gap: 1rem; + padding: 1.15rem; + border-radius: 1.5rem; + background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong)); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-soft); + + @media (min-width: 880px) { + grid-template-columns: minmax(0, 1.2fr) minmax(14rem, 0.8fr); + align-items: start; + padding: 1.35rem; + } +} + +.heroCopy { + display: grid; + gap: 0.55rem; + + h1 { + font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem); + line-height: 1; + letter-spacing: -0.04em; + } + + p:last-child { + color: var(--text-muted); + font-size: 0.96rem; + max-width: 58ch; + } +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.75rem; + color: var(--text-muted); +} + +.profileChip { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 1rem; + border-radius: 1.15rem; + background: var(--surface-soft); + border: 1px solid var(--border-soft); + + strong { + display: block; + font-size: 1rem; + } + + span:last-child { + color: var(--text-muted); + font-size: 0.88rem; + } +} + +.avatar { + display: inline-grid; + place-items: center; + width: 2.75rem; + height: 2.75rem; + border-radius: 9999px; + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + font-weight: 600; + flex-shrink: 0; +} + +.statGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + + @media (min-width: 520px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + @media (min-width: 880px) { + grid-column: 1 / -1; + } +} + +.statCard { + display: grid; + gap: 0.2rem; + padding: 0.95rem; + border-radius: 1.15rem; + background: var(--surface-soft); + border: 1px solid var(--border-soft); + + strong { + font-size: 1.1rem; + line-height: 1.1; + } + + span { + font-size: 0.84rem; + color: var(--text-muted); + } +} + +.panelGrid { + display: grid; + gap: 1rem; + + @media (min-width: 1040px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1.25rem; + } +} + +.panelCard { + display: grid; + gap: 1rem; + padding: 1.15rem; + border-radius: 1.35rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); +} + +.panelBody { + display: grid; + gap: 1rem; +} + +.panelCopy { + display: grid; + gap: 0.35rem; + + h2 { + font-size: 1.2rem; + line-height: 1.1; + letter-spacing: -0.03em; + } + + p { + font-size: 0.92rem; + color: var(--text-muted); + } +} + +.rowList { + display: grid; + gap: 0.65rem; +} + +.rowItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 0.9rem; + border-radius: 1rem; + background: var(--surface-soft); + border: 1px solid var(--border-soft); + + span { + font-size: 0.86rem; + color: var(--text-muted); + } + + strong { + font-size: 0.92rem; + text-align: right; + } +} + +.panelActions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.primaryAction, +.secondaryAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.8rem 1rem; + border-radius: 9999px; + font-weight: 500; + text-decoration: none; + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; +} + +.primaryAction { + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + box-shadow: var(--action-primary-shadow); + + &:hover { + transform: translateY(-1px); + box-shadow: var(--action-primary-shadow-hover); + } +} + +.secondaryAction { + background: var(--surface-soft); + color: var(--text); + border: 1px solid var(--border-soft); + + &:hover { + transform: translateY(-1px); + background: var(--surface-panel-strong); + } +} diff --git a/Frontend/src/components/dashboard/dashboard-settings-focus.tsx b/Frontend/src/components/dashboard/dashboard-settings-focus.tsx new file mode 100644 index 0000000..2a56d67 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-settings-focus.tsx @@ -0,0 +1,77 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { A } from "@solidjs/router"; +import { settingsFocusPanels, settingsFocusStats, topbarSummary } from "./dashboard.data"; +import styles from "./dashboard-settings-focus.module.scss"; + +const DashboardSettingsFocus: Component = () => { + return ( +
+
+
+

Settings

+

Your dashboard settings

+

Keep this inside the dashboard shell so profile details, study preferences, and learner goals feel like part of the same student workspace.

+
+ +
+ {topbarSummary.profileBadge} +
+ {topbarSummary.profileName} + {topbarSummary.profileRole} +
+
+ +
+ + {(stat) => ( +
+ {stat.value} + {stat.label} +
+ )} +
+
+
+ +
+ + {(panel) => ( + + )} + +
+
+ ); +}; + +export default DashboardSettingsFocus; diff --git a/Frontend/src/components/dashboard/dashboard-shell.tsx b/Frontend/src/components/dashboard/dashboard-shell.tsx new file mode 100644 index 0000000..b7f8d9f --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-shell.tsx @@ -0,0 +1,81 @@ +import { createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from "solid-js"; +import DashboardSidebar from "./dashboard-sidebar"; +import DashboardTopbar from "./dashboard-topbar"; +import styles from "../../routes/dashboard/dashboard.module.scss"; + +type DashboardShellProps = { + children: JSX.Element; +}; + +const DashboardShell: Component = (props) => { + const [sidebarOpen, setSidebarOpen] = createSignal(false); + const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = createSignal(false); + + const closeSidebar = () => setSidebarOpen(false); + const isDesktopViewport = () => typeof window !== "undefined" && window.innerWidth >= 1024; + const isSidebarVisible = () => (isDesktopViewport() ? !desktopSidebarCollapsed() : sidebarOpen()); + const toggleSidebar = () => { + if (isDesktopViewport()) { + setDesktopSidebarCollapsed((collapsed) => !collapsed); + return; + } + + setSidebarOpen((open) => !open); + }; + + onMount(() => { + const handleResize = () => { + if (window.innerWidth >= 1024) { + setSidebarOpen(false); + } + }; + + window.addEventListener("resize", handleResize); + onCleanup(() => window.removeEventListener("resize", handleResize)); + }); + + createEffect(() => { + if (typeof document === "undefined") return; + + const shouldLockScroll = sidebarOpen() && window.innerWidth < 1024; + document.body.style.overflow = shouldLockScroll ? "hidden" : ""; + onCleanup(() => { + document.body.style.overflow = ""; + }); + }); + + return ( +
+
+
+
+ +
+ + {props.children} +
+
+
+ ); +}; + +export default DashboardShell; diff --git a/Frontend/src/components/dashboard/dashboard-sidebar.module.scss b/Frontend/src/components/dashboard/dashboard-sidebar.module.scss new file mode 100644 index 0000000..952c8d5 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-sidebar.module.scss @@ -0,0 +1,238 @@ +.sidebar { + display: grid; + gap: 1rem; + padding: 1rem; + background: var(--surface-panel); + border: 1px solid var(--border-strong); + border-radius: 1.5rem; + box-shadow: var(--shadow-soft); + + @media (min-width: 768px) { + padding: 1.15rem; + } + + @media (min-width: 1024px) { + gap: 1.25rem; + padding: 1.3rem; + position: sticky; + top: 1rem; + } +} + +.brand { + display: flex; + align-items: center; + gap: 0.85rem; +} + +.logoMark { + width: 2.6rem; + height: 2.6rem; + display: grid; + place-items: center; + border-radius: 0.9rem; + background: linear-gradient(135deg, var(--action-primary-start), var(--info)); + color: var(--action-primary-text); + font-weight: 600; + box-shadow: var(--action-primary-shadow); +} + +.brandName { + font-weight: 600; + font-size: 1rem; +} + +.brandMeta { + color: var(--text-muted); + font-size: 0.9rem; +} + +.navigation { + display: grid; + gap: 0.35rem; + + @media (max-width: 519px) { + display: flex; + overflow-x: auto; + gap: 0.55rem; + padding-bottom: 0.1rem; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + @media (min-width: 520px) and (max-width: 1023px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.link { + display: flex; + align-items: flex-start; + gap: 0.8rem; + padding: 0.78rem 0.85rem; + border-radius: 1rem; + color: var(--text-muted); + transition: + background-color 0.2s ease, + color 0.2s ease, + transform 0.2s ease; + + &:hover { + text-decoration: none; + background: var(--surface-soft); + color: var(--text); + transform: translateX(2px); + } +} + +@media (max-width: 519px) { + .link { + flex: 0 0 auto; + align-items: center; + gap: 0.6rem; + padding: 0.7rem 0.82rem; + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + white-space: nowrap; + } +} + +.linkCopy { + display: grid; + gap: 0.08rem; + min-width: 0; + + strong { + color: var(--text); + font-size: 0.94rem; + font-weight: 500; + line-height: 1.15; + } + + small { + color: var(--text-muted); + font-size: 0.8rem; + line-height: 1.2; + } +} + +@media (max-width: 519px) { + .linkCopy { + gap: 0; + + strong { + font-size: 0.9rem; + } + + small { + display: none; + } + } +} + +.active { + background: var(--surface-info); + color: var(--info); + + .iconSlot { + background: var(--blue-100); + border-color: color-mix(in srgb, var(--info) 18%, var(--border-divider)); + color: var(--info); + } + + .linkCopy { + strong, + small { + color: var(--info); + } + } +} + +.iconSlot { + width: 1.9rem; + height: 1.9rem; + display: grid; + place-items: center; + border-radius: 0.65rem; + background: var(--surface-panel-strong); + border: 1px solid var(--border-divider); + font-size: 0.8rem; + font-weight: 600; + flex-shrink: 0; +} + +.supportCard { + display: grid; + gap: 0.8rem; + margin-top: 0.25rem; + padding: 1rem; + background: linear-gradient(180deg, var(--surface-panel-strong), var(--surface-soft)); + border: 1px solid var(--border-soft); + border-radius: 1.25rem; +} + +@media (max-width: 519px) { + .supportCard { + gap: 0.7rem; + padding: 0.9rem; + + p { + font-size: 0.86rem; + } + } +} + +.avatarRow { + display: flex; + align-items: center; + + span { + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + border-radius: 9999px; + border: 2px solid var(--surface-panel); + background: var(--surface-accent-soft); + color: var(--primary); + font-size: 0.85rem; + font-weight: 600; + + &:not(:first-child) { + margin-left: -0.4rem; + } + } +} + +.supportCard h2 { + font-size: 1.05rem; + font-weight: 500; + line-height: 1.15; +} + +.supportCard p { + color: var(--text-muted); + font-size: 0.92rem; +} + +.supportButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.8rem 1rem; + border: none; + border-radius: 9999px; + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + box-shadow: var(--action-primary-shadow); + cursor: pointer; + font-weight: 600; + white-space: nowrap; + + &:hover { + text-decoration: none; + } +} diff --git a/Frontend/src/components/dashboard/dashboard-sidebar.tsx b/Frontend/src/components/dashboard/dashboard-sidebar.tsx new file mode 100644 index 0000000..4d68436 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-sidebar.tsx @@ -0,0 +1,62 @@ +import type { Component } from "solid-js"; +import { For } from "solid-js"; +import { A, useLocation } from "@solidjs/router"; +import { classroomSummary, sidebarLinks, sidebarSupport } from "./dashboard.data"; +import styles from "./dashboard-sidebar.module.scss"; + +type DashboardSidebarProps = { + onNavigate?: () => void; +}; + +const DashboardSidebar: Component = (props) => { + const location = useLocation(); + + const isActiveLink = (href?: string) => { + if (!href) return false; + if (href === "/dashboard") return location.pathname === "/dashboard"; + return location.pathname === href || location.pathname.startsWith(`${href}/`); + }; + + return ( + + ); +}; + +export default DashboardSidebar; diff --git a/Frontend/src/components/dashboard/dashboard-theme-toggle.module.scss b/Frontend/src/components/dashboard/dashboard-theme-toggle.module.scss new file mode 100644 index 0000000..193b25f --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-theme-toggle.module.scss @@ -0,0 +1,75 @@ +.toggleButton { + display: grid; + place-items: center; + width: 2.75rem; + height: 2.75rem; + padding: 0; + border-radius: 1rem; + border: 1px solid var(--border-soft); + background: var(--surface-panel); + color: var(--text-muted); + box-shadow: var(--shadow-soft); + cursor: pointer; + transition: + transform 0.2s ease, + background-color 0.2s ease, + border-color 0.2s ease; + + &:hover { + transform: translateY(-1px); + background: var(--surface-panel-strong); + border-color: var(--border-strong); + } + + @media (min-width: 768px) { + width: 3rem; + height: 3rem; + } +} + +.iconContainer { + position: relative; + width: 1.2rem; + height: 1.2rem; +} + +.colorIcon { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + transition: + transform 700ms ease, + opacity 300ms ease; + + svg { + width: 1.2rem; + height: 1.2rem; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } +} + +.moonWrapper { + transform: rotate(90deg); + opacity: 0; + + :global([data-color-scheme="dark"]) & { + transform: rotate(0deg); + opacity: 1; + } +} + +.sunWrapper { + transform: rotate(0deg); + opacity: 1; + + :global([data-color-scheme="dark"]) & { + transform: rotate(-90deg); + opacity: 0; + } +} diff --git a/Frontend/src/components/dashboard/dashboard-theme-toggle.tsx b/Frontend/src/components/dashboard/dashboard-theme-toggle.tsx new file mode 100644 index 0000000..a7de64c --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-theme-toggle.tsx @@ -0,0 +1,27 @@ +import type { Component } from "solid-js"; +import { useTheme } from "../../context/theme/context"; +import styles from "./dashboard-theme-toggle.module.scss"; + +const DashboardThemeToggle: Component = () => { + const { toggleMode } = useTheme(); + + return ( + + ); +}; + +export default DashboardThemeToggle; diff --git a/Frontend/src/components/dashboard/dashboard-topbar.module.scss b/Frontend/src/components/dashboard/dashboard-topbar.module.scss new file mode 100644 index 0000000..5859676 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-topbar.module.scss @@ -0,0 +1,451 @@ +.topbar { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.85rem; + align-items: center; + position: relative; + z-index: 50; + + @media (max-width: 519px) { + grid-template-columns: auto minmax(0, 1fr); + } + + @media (min-width: 880px) { + grid-template-columns: auto minmax(0, 1fr) auto; + } +} + + +.menuButton, +.iconButton { + width: 2.75rem; + height: 2.75rem; + display: grid; + place-items: center; + border-radius: 1rem; + border: 1px solid var(--border-soft); + background: var(--surface-panel); + color: var(--text-muted); + box-shadow: var(--shadow-soft); + cursor: pointer; + transition: + transform 0.2s ease, + background-color 0.2s ease, + border-color 0.2s ease; + + &:hover { + transform: translateY(-1px); + background: var(--surface-panel-strong); + border-color: var(--border-strong); + } + + svg { + width: 1.15rem; + height: 1.15rem; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } +} + +.menuButton { + position: relative; + z-index: 70; +} + +.menuGlyph { + position: relative; + width: 1.1rem; + height: 0.95rem; + display: inline-block; +} + +.menuLine { + position: absolute; + left: 0; + width: 100%; + height: 2px; + border-radius: 9999px; + background: currentColor; + transform-origin: center; + transition: + transform 0.38s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.24s var(--transition-ease), + top 0.38s cubic-bezier(0.22, 1, 0.36, 1), + background-color 0.2s var(--transition-ease); + + &:nth-child(1) { + top: 0.05rem; + } + + &:nth-child(2) { + top: 0.41rem; + } + + &:nth-child(3) { + top: 0.77rem; + } +} + +.menuButtonOpen { + .menuLine { + &:nth-child(1) { + top: 0.41rem; + transform: rotate(45deg); + } + + &:nth-child(2) { + opacity: 0; + transform: scaleX(0.4); + } + + &:nth-child(3) { + top: 0.41rem; + transform: rotate(-45deg); + } + } +} + +@media (min-width: 768px) { + .menuButton, + .iconButton { + width: 3rem; + height: 3rem; + } +} + +.searchField { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0 0.85rem; + border-radius: 1rem; + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + box-shadow: var(--shadow-soft); + + input { + width: 100%; + padding: 0.82rem 0; + border: none; + background: transparent; + outline: none; + color: var(--text); + font-size: 0.9rem; + } +} + +@media (min-width: 768px) { + .searchField { + padding: 0 1rem; + + input { + padding: 0.95rem 0; + font-size: 0.95rem; + } + } +} + +.searchIcon { + display: grid; + place-items: center; + color: var(--text-muted); + + svg { + width: 1rem; + height: 1rem; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } +} + +.actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.55rem; + grid-column: 1 / -1; + overflow-x: auto; + padding-bottom: 0.15rem; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + @media (min-width: 880px) { + grid-column: auto; + justify-content: flex-end; + overflow: visible; + padding-bottom: 0; + } +} + +.menuGroup { + position: relative; + flex: 0 0 auto; +} + +.countBadge { + position: absolute; + top: -0.15rem; + right: -0.15rem; + min-width: 1.1rem; + height: 1.1rem; + display: grid; + place-items: center; + padding: 0 0.2rem; + border-radius: 9999px; + background: var(--primary); + color: var(--action-primary-text); + font-size: 0.68rem; + font-weight: 700; + line-height: 1; + box-shadow: var(--shadow-soft); +} + +.dropdown { + position: absolute; + top: calc(100% + 0.6rem); + right: 0; + width: min(22rem, calc(100vw - 2rem)); + display: grid; + gap: 0.85rem; + padding: 0.95rem; + border-radius: 1.25rem; + border: 1px solid var(--border-strong); + background: var(--surface-raised); + box-shadow: var(--shadow-elevated); + backdrop-filter: blur(18px); + z-index: 20; +} + +.dropdownHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + + strong { + font-size: 1rem; + line-height: 1.1; + } +} + +.dropdownEyebrow { + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 0.12rem; +} + +.dropdownLink { + color: var(--primary); + font-size: 0.85rem; + font-weight: 600; + white-space: nowrap; +} + +.dropdownList { + display: grid; + gap: 0.65rem; +} + +.dropdownItem { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: start; + gap: 0.7rem; + padding: 0.8rem; + border-radius: 1rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + transition: + transform 0.2s ease, + border-color 0.2s ease, + background-color 0.2s ease; + + &:hover { + transform: translateY(-1px); + border-color: var(--border-strong); + background: var(--surface-panel-strong); + text-decoration: none; + } + + small { + color: var(--text-muted); + font-size: 0.74rem; + white-space: nowrap; + } +} + +.itemCopy { + min-width: 0; + display: grid; + gap: 0.2rem; + + strong { + font-size: 0.92rem; + line-height: 1.15; + } + + p { + font-size: 0.82rem; + line-height: 1.35; + color: var(--text-muted); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } +} + +.itemTone, +.messageAvatar { + width: 2.15rem; + height: 2.15rem; + border-radius: 9999px; + flex-shrink: 0; +} + +.itemTone { + display: inline-block; + margin-top: 0.1rem; + border: 1px solid var(--border-soft); +} + +.tone-blue { + background: var(--surface-info-emphasis); +} + +.tone-yellow { + background: var(--surface-warning-emphasis); +} + +.tone-teal { + background: var(--surface-success-emphasis); +} + +.messageAvatar { + display: grid; + place-items: center; + background: linear-gradient(135deg, var(--surface-accent-emphasis), var(--surface-info-emphasis)); + color: var(--text-accent-strong); + font-size: 0.8rem; + font-weight: 700; + margin-top: 0.1rem; +} + +.profile { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0.5rem 0.4rem 0.8rem; + border-radius: 9999px; + border: 1px solid var(--border-soft); + background: var(--surface-panel); + box-shadow: var(--shadow-soft); + white-space: nowrap; +} + +.profileButton { + padding: 0; + border: none; + background: transparent; + cursor: pointer; + color: inherit; + + &:hover { + text-decoration: none; + } + + .profile { + transition: + transform 0.2s ease, + border-color 0.2s ease, + background-color 0.2s ease; + } + + &:hover .profile, + &:focus-visible .profile, + &.profileButtonOpen .profile { + transform: translateY(-1px); + background: var(--surface-panel-strong); + border-color: var(--border-strong); + } +} + +.profileChevron { + display: grid; + place-items: center; + color: var(--text-muted); + transition: transform 0.24s var(--transition-ease); + + svg { + width: 0.95rem; + height: 0.95rem; + stroke: currentColor; + fill: none; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; + } +} + +.profileButtonOpen .profileChevron { + transform: rotate(180deg); +} + +@media (min-width: 768px) { + .profile { + padding: 0.45rem 0.55rem 0.45rem 0.95rem; + } +} + +.profileName { + font-weight: 500; + font-size: 0.9rem; +} + +.profileRole { + font-size: 0.85rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +@media (max-width: 519px) { + .profileRole { + display: none; + } +} + +.profileBadge { + width: 2.2rem; + height: 2.2rem; + display: grid; + place-items: center; + border-radius: 9999px; + background: var(--surface-accent-emphasis); + color: var(--text-accent-strong); + font-weight: 600; +} + +.profileMenuBadge { + width: 2.2rem; + height: 2.2rem; + display: grid; + place-items: center; + border-radius: 9999px; + background: var(--surface-accent-emphasis); + color: var(--text-accent-strong); + font-size: 0.9rem; + font-weight: 700; + flex-shrink: 0; +} diff --git a/Frontend/src/components/dashboard/dashboard-topbar.tsx b/Frontend/src/components/dashboard/dashboard-topbar.tsx new file mode 100644 index 0000000..e413778 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard-topbar.tsx @@ -0,0 +1,218 @@ +import { A } from "@solidjs/router"; +import { createSignal, For, onCleanup, onMount, Show, type Component } from "solid-js"; +import DashboardThemeToggle from "./dashboard-theme-toggle"; +import { topbarMessages, topbarNotifications, topbarSummary } from "./dashboard.data"; +import styles from "./dashboard-topbar.module.scss"; + +type DashboardTopbarProps = { + isSidebarOpen: boolean; + onMenuToggle: () => void; +}; + +const DashboardTopbar: Component = (props) => { + const [openMenu, setOpenMenu] = createSignal<"notifications" | "messages" | "profile" | null>(null); + let menuRoot: HTMLDivElement | undefined; + + const toggleMenu = (menu: "notifications" | "messages" | "profile") => { + setOpenMenu((current) => (current === menu ? null : menu)); + }; + + onMount(() => { + const handlePointerDown = (event: MouseEvent) => { + if (!menuRoot?.contains(event.target as Node)) { + setOpenMenu(null); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown)); + }); + + return ( +
+ + + + +
+ + +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ ); +}; + +export default DashboardTopbar; diff --git a/Frontend/src/components/dashboard/dashboard.data.ts b/Frontend/src/components/dashboard/dashboard.data.ts new file mode 100644 index 0000000..49d9fa2 --- /dev/null +++ b/Frontend/src/components/dashboard/dashboard.data.ts @@ -0,0 +1,774 @@ +import rawDataset from "../../../../Mock-Data/dataset.json"; + +type SidebarLink = { + label: string; + detail?: string; + icon: string; + href?: string; + active?: boolean; +}; + +type SpotlightStat = { + label: string; + value: string; + tone: "purple" | "yellow" | "blue"; +}; + +type QuickStat = { + label: string; + value: string; +}; + +type AssignmentCard = { + title: string; + lessons: string; + accent: "yellow" | "pink" | "teal" | "blue"; + cta: string; + href: string; +}; + +type AssignmentFocusStat = { + label: string; + value: string; +}; + +type AssignmentFocusItem = { + title: string; + meta: string; + subMeta: string; + statusLabel: string; + progressText: string; + tone: "yellow" | "teal" | "blue"; + primaryLabel: string; + primaryHref: string; + secondaryLabel: string; + secondaryHref: string; +}; + +type AssignmentFocusGroup = { + title: string; + description: string; + items: AssignmentFocusItem[]; +}; + +type PracticeFocusStat = { + label: string; + value: string; +}; + +type PracticeFocusCard = { + title: string; + description: string; + meta: string; + tone: "yellow" | "teal" | "blue"; + primaryLabel: string; + primaryHref: string; + secondaryLabel: string; + secondaryHref: string; +}; + +type MessageFocusStat = { + label: string; + value: string; +}; + +type MessageFocusThread = { + sender: string; + role: string; + initials: string; + preview: string; + timestamp: string; + unread?: boolean; + actionLabel: string; + actionHref: string; +}; + +type TopbarNotificationItem = { + title: string; + description: string; + timestamp: string; + href: string; + tone: "blue" | "yellow" | "teal"; +}; + +type SettingsFocusStat = { + label: string; + value: string; +}; + +type SettingsFocusPanel = { + title: string; + description: string; + rows: Array<{ + label: string; + value: string; + }>; + primaryLabel: string; + primaryHref: string; + secondaryLabel?: string; + secondaryHref?: string; +}; + +type StudentSupportCard = { + name: string; + meta: string; + initials: string; + actionLabel: string; + href: string; +}; + +type HighlightCard = { + value: string; + label: string; + note: string; + tone: "yellow" | "pink"; +}; + +type PerformanceBar = { + label: string; + value: number; + tone: "blue" | "purple" | "teal" | "pink" | "yellow"; +}; + +type UsageItem = { + label: string; + value: number; + tone: "blue" | "purple" | "teal" | "yellow"; +}; + +type Dataset = { + _meta: { + reference_today: string; + students: number; + assignments: number; + questions_in_bank: number; + student_answers: number; + expected_top_3_at_risk_student_ids: number[]; + }; + classroom: { + name: string; + invite_code: string; + target_level: number; + }; + tutor: { + fullname: string; + role: string; + }; + students: Array<{ + id: number; + fullname: string; + _persona: string; + }>; + assignments: Array<{ + id: number; + name: string; + topic: string; + status: "DRAFT" | "PUBLISHED" | "CLOSED"; + due_date: number; + maximum_marks: number; + }>; + assignment_assignees: Array<{ + id: number; + assignment_id: number; + student_id: number; + status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED"; + total_marks: number; + }>; + student_answers: Array<{ + assignee_id: number; + _is_correct: boolean; + _solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; + _question_topic: string; + _answered_at: number; + }>; + activity_logs: Array<{ + timestamp: number; + duration_seconds: number; + _student_id: number; + }>; +}; + +type StudentAssignment = Dataset["assignment_assignees"][number] & { + assignment: Dataset["assignments"][number]; + answerCount: number; + accuracy: number; +}; + +const dataset = rawDataset as Dataset; +const referenceTime = new Date(`${dataset._meta.reference_today}T00:00:00Z`).getTime(); +const referenceDate = new Date(referenceTime); +const msPerDay = 1000 * 60 * 60 * 24; +const defaultStudentId = 201; + +const students = dataset.students; +const assignments = [...dataset.assignments].sort((left, right) => left.due_date - right.due_date); +const assignees = dataset.assignment_assignees; +const answers = dataset.student_answers; +const activityLogs = dataset.activity_logs; + +const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0]; + +const initialsFor = (name: string) => + name + .split(" ") + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); + +const firstName = student.fullname.split(" ")[0]; + +const formatCompactNumber = (value: number) => new Intl.NumberFormat("en-GB", { notation: "compact", maximumFractionDigits: 1 }).format(value); +const formatPercent = (value: number) => `${Math.round(value)}%`; + +const formatDueDate = (timestamp: number) => + new Intl.DateTimeFormat("en-GB", { + month: "short", + day: "numeric", + }).format(new Date(timestamp)); + +const formatHours = (seconds: number) => `${(seconds / 3600).toFixed(1)}h`; + +const daysUntil = (timestamp: number) => Math.max(0, Math.ceil((timestamp - referenceTime) / msPerDay)); + +const formatLastSeen = (timestamp: number) => { + const dayDiff = Math.max(0, Math.round((referenceTime - timestamp) / msPerDay)); + if (dayDiff <= 0) return "today"; + if (dayDiff === 1) return "yesterday"; + return `${dayDiff} days ago`; +}; + +const topicTone = ["pink", "yellow", "blue", "purple", "teal"] as const; + +const assignmentById = new Map(assignments.map((assignment) => [assignment.id, assignment])); +const assigneeById = new Map(assignees.map((assignee) => [assignee.id, assignee])); + +const studentAnswers = answers.filter((answer) => assigneeById.get(answer.assignee_id)?.student_id === student.id); +const studentLogs = activityLogs.filter((log) => log._student_id === student.id); + +const studentAssignments: StudentAssignment[] = assignees + .filter((assignee) => assignee.student_id === student.id) + .map((assignee) => { + const assignment = assignmentById.get(assignee.assignment_id); + if (!assignment) throw new Error(`Missing assignment ${assignee.assignment_id}`); + + const assignmentAnswers = studentAnswers.filter((answer) => answer.assignee_id === assignee.id); + const answerCount = assignmentAnswers.length; + const correct = assignmentAnswers.filter((answer) => answer._is_correct).length; + + return { + ...assignee, + assignment, + answerCount, + accuracy: answerCount > 0 ? Math.round((correct / answerCount) * 100) : 0, + }; + }) + .sort((left, right) => left.assignment.due_date - right.assignment.due_date); + +const totalCorrectAnswers = studentAnswers.filter((answer) => answer._is_correct).length; +const overallAccuracy = studentAnswers.length > 0 ? (totalCorrectAnswers / studentAnswers.length) * 100 : 0; +const totalDurationSeconds = studentLogs.reduce((sum, log) => sum + log.duration_seconds, 0); +const latestActivityTime = Math.max(...studentLogs.map((log) => log.timestamp)); + +const submittedAssignments = studentAssignments.filter((assignment) => assignment.status === "SUBMITTED"); +const inProgressAssignments = studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS"); +const notStartedAssignments = studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED"); +const pendingAssignments = studentAssignments.filter((assignment) => assignment.status !== "SUBMITTED"); + +const currentAssignment = inProgressAssignments[0] ?? notStartedAssignments[0] ?? studentAssignments[studentAssignments.length - 1]; +const nextNotStartedAssignment = notStartedAssignments[0]; + +const topicBuckets = new Map(); +for (const answer of studentAnswers) { + const bucket = topicBuckets.get(answer._question_topic) ?? { correct: 0, total: 0 }; + bucket.correct += Number(answer._is_correct); + bucket.total += 1; + topicBuckets.set(answer._question_topic, bucket); +} + +const topicPerformance = [...topicBuckets.entries()].map(([label, bucket]) => ({ + label, + value: Math.round((bucket.correct / bucket.total) * 100), + total: bucket.total, +})); + +const strongestTopic = [...topicPerformance].sort((left, right) => right.value - left.value || right.total - left.total)[0]; +const weakestTopic = [...topicPerformance].sort((left, right) => left.value - right.value || right.total - left.total)[0]; + +const topicMasteryBars: PerformanceBar[] = [...topicPerformance] + .sort((left, right) => right.total - left.total || left.value - right.value) + .slice(0, 5) + .sort((left, right) => left.value - right.value) + .map((topic, index) => ({ + label: topic.label, + value: topic.value, + tone: topicTone[index % topicTone.length], + })); + +const solveModeBuckets = new Map(); +for (const answer of studentAnswers) { + solveModeBuckets.set(answer._solve_mode, (solveModeBuckets.get(answer._solve_mode) ?? 0) + 1); +} + +const solveModeToneMap: Record = { + just_answer: "purple", + step_by_step: "blue", + solve_together: "teal", + handwritten: "yellow", +}; + +const solveModeLabelMap: Record = { + just_answer: "Just answer", + step_by_step: "Step by step", + solve_together: "Solve together", + handwritten: "Handwritten", +}; + +const solveModeUsage: UsageItem[] = [...solveModeBuckets.entries()] + .map(([mode, count]) => ({ + label: solveModeLabelMap[mode] ?? mode, + value: Math.round((count / studentAnswers.length) * 100), + tone: solveModeToneMap[mode] ?? "blue", + })) + .sort((left, right) => right.value - left.value); + +const topSolveMode = solveModeUsage[0]; + +const personaSummary: Record = { + fraction_inversion: { + title: "Fractions is your focus this week", + recommendation: "Slow down on equivalent fractions and practise inversion rules before test prep.", + }, + place_value_gaps: { + title: "Place value is worth another pass", + recommendation: "Use quick warm-ups before multi-step arithmetic to avoid slips early in the question.", + }, + rushed_careless: { + title: "A calmer pace will lift your accuracy", + recommendation: "Use Step by step more often on tougher questions so careless mistakes do not snowball.", + }, + solve_together_dependent: { + title: "Independent confidence is your next goal", + recommendation: "Try one question solo before switching to Solve together so you build recall first.", + }, + word_problem_weak: { + title: "Word problems are the main thing to unlock", + recommendation: "Underline key information and turn the question into a quick number sentence first.", + }, + stable_strong: { + title: "You are in a strong learning rhythm", + recommendation: "Keep stretching with harder mixed practice so the easier wins stay automatic.", + }, + stable_mid: { + title: "You are close to a really strong streak", + recommendation: "A few focused sessions on weaker topics should move your average up quickly.", + }, + stable_weak: { + title: "A steadier routine will help most right now", + recommendation: "Aim for short, regular practice blocks before trying bigger mixed assignments.", + }, +}; + +const activePersona = personaSummary[student._persona] ?? { + title: "Keep building momentum", + recommendation: "Stay focused on one weak area at a time and finish current work before starting new tasks.", +}; + +const assignmentAccent = (status: StudentAssignment["status"]): AssignmentCard["accent"] => { + switch (status) { + case "IN_PROGRESS": + return "blue"; + case "NOT_STARTED": + return "yellow"; + default: + return "teal"; + } +}; + +const assignmentCta = (status: StudentAssignment["status"]) => { + switch (status) { + case "IN_PROGRESS": + return "Resume now"; + case "NOT_STARTED": + return "Start now"; + default: + return "View recap"; + } +}; + +const assignmentStatusCopy = (assignment: StudentAssignment) => { + if (assignment.status === "SUBMITTED") { + return `${assignment.assignment.topic} · Submitted · Score ${assignment.total_marks}/${assignment.assignment.maximum_marks}`; + } + + if (assignment.status === "IN_PROGRESS") { + return `${assignment.assignment.topic} · ${assignment.answerCount} questions answered · Due ${formatDueDate(assignment.assignment.due_date)}`; + } + + return `${assignment.assignment.topic} · Not started · Due ${formatDueDate(assignment.assignment.due_date)}`; +}; + +const assignmentCards: AssignmentCard[] = [...studentAssignments] + .sort((left, right) => { + const weight = { IN_PROGRESS: 0, NOT_STARTED: 1, SUBMITTED: 2 } as const; + return weight[left.status] - weight[right.status] || left.assignment.due_date - right.assignment.due_date; + }) + .slice(0, 4) + .map((assignment) => ({ + title: assignment.assignment.name, + lessons: assignmentStatusCopy(assignment), + accent: assignmentAccent(assignment.status), + cta: assignmentCta(assignment.status), + href: `/assignment/${assignment.assignment.id}`, + })); + +const submittedTrend = submittedAssignments.slice(-6).map((assignment) => ({ + label: assignment.assignment.topic.length > 8 ? assignment.assignment.topic.slice(0, 8) : assignment.assignment.topic, + value: assignment.assignment.maximum_marks > 0 ? Math.round((assignment.total_marks / assignment.assignment.maximum_marks) * 100) : 0, +})); + +const usageSummary = { + note: topSolveMode + ? `You use ${topSolveMode.label} for ${topSolveMode.value}% of your answers. When ${weakestTopic?.label?.toLowerCase() ?? "a topic"} gets tricky, try Step by step for a calmer second try.` + : "Try a mix of independent and guided solving to learn what helps you most.", +}; + +export const classroomSummary = { + name: dataset.classroom.name, + targetLevel: dataset.classroom.target_level, + inviteCode: dataset.classroom.invite_code, + tutorName: dataset.tutor.fullname, + tutorRole: "Lead tutor", + tutorInitials: initialsFor(dataset.tutor.fullname), +}; + +export const sidebarLinks: SidebarLink[] = [ + { label: "Home", detail: "Today", icon: "⌂", href: "/dashboard", active: true }, + { label: "Assignments", detail: `${pendingAssignments.length} live`, icon: "✓", href: "/dashboard/assignments" }, + { label: "Progress", detail: `${Math.round(overallAccuracy)}% accuracy`, icon: "↗", href: "/dashboard/progress" }, + { label: "Practice", detail: weakestTopic?.label ?? "Mixed skills", icon: "✦", href: "/dashboard/practice" }, + { label: "Messages", detail: dataset.tutor.fullname.split(" ")[0], icon: "✉", href: "/dashboard/messages" }, + { label: "Settings", detail: "Profile & goals", icon: "⋯", href: "/dashboard/settings" }, +]; + +export const sidebarSupport = { + avatars: [currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(), weakestTopic?.label.slice(0, 2) ?? "WK", nextNotStartedAssignment?.assignment.name.replace("HW", "H").split("—")[0].trim() ?? "GO"], + title: "Today’s study plan", + description: `Pick up ${currentAssignment.assignment.name.split("—")[0].trim()}, spend 15 minutes on ${weakestTopic?.label ?? "your focus topic"}, then start ${nextNotStartedAssignment ? nextNotStartedAssignment.assignment.name.split("—")[0].trim() : "your next task"} while it still feels easy.`, + buttonLabel: "Open my plan", + buttonHref: `/assignment/${currentAssignment.assignment.id}/work`, +}; + +export const topbarSummary = { + searchPlaceholder: "Search assignments, hints, or question topics", + profileName: student.fullname, + profileRole: `${dataset.classroom.name} · Student`, + profileBadge: initialsFor(student.fullname), + notificationCount: 3, + messageCount: 1, +}; + +export const topbarNotifications: TopbarNotificationItem[] = [ + { + title: `${currentAssignment.assignment.name.split("—")[0].trim()} is still live`, + description: currentAssignment.status === "SUBMITTED" + ? `You have already finished this task. Open the review and clean up any missed marks.` + : `${currentAssignment.answerCount} questions are already touched. Finish it before ${formatDueDate(currentAssignment.assignment.due_date)}.`, + timestamp: `${daysUntil(currentAssignment.assignment.due_date)}d left`, + href: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`, + tone: currentAssignment.status === "SUBMITTED" ? "teal" : "blue", + }, + { + title: `${weakestTopic?.label ?? "Focus practice"} needs a quick pass`, + description: weakestTopic + ? `${weakestTopic.value}% accuracy across ${weakestTopic.total} recent questions. A short focused block here should move the needle fastest.` + : "Open a short practice block and work through your next weak spot.", + timestamp: "Practice now", + href: "/dashboard/practice", + tone: "yellow", + }, + { + title: `${dataset.tutor.fullname.split(" ")[0]} left feedback`, + description: `There is a fresh tutor message waiting with a calm next-step suggestion for ${weakestTopic?.label?.toLowerCase() ?? "your current focus"}.`, + timestamp: "Today", + href: "/dashboard/messages", + tone: "teal", + }, +]; + +export const heroSummary = { + eyebrow: `Welcome back, ${firstName}`, + title: activePersona.title, + description: `You have finished ${submittedAssignments.length} assignments so far. Right now, the best next step is to keep ${currentAssignment.assignment.name.split("—")[0].trim()} moving and give ${weakestTopic?.label ?? "your focus topic"} a short confidence boost.`, + visualBadges: [formatPercent(overallAccuracy), `${currentAssignment.assignment.name.split("—")[0].trim()} live`, `${weakestTopic?.label ?? "Focus"} focus`], +}; + +export const spotlightStats: SpotlightStat[] = [ + { label: "Assignments done", value: `${submittedAssignments.length}/${studentAssignments.length}`, tone: "purple" }, + { label: "Current focus", value: weakestTopic?.label ?? "Mixed practice", tone: "yellow" }, + { label: "Best topic", value: strongestTopic?.label ?? "Building", tone: "blue" }, +]; + +export const heroSideCard = { + title: currentAssignment.status === "SUBMITTED" ? `Nice work on ${currentAssignment.assignment.name.split("—")[0].trim()}` : `Pick up where you left off`, + description: + currentAssignment.status === "SUBMITTED" + ? `You scored ${currentAssignment.total_marks}/${currentAssignment.assignment.maximum_marks}. A quick recap now will make the next assignment feel easier.` + : `${currentAssignment.answerCount} questions are already done. Finish it by ${formatDueDate(currentAssignment.assignment.due_date)} while the topic is still fresh.`, + buttonLabel: currentAssignment.status === "SUBMITTED" ? "Review this work" : "Resume assignment", + buttonHref: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`, +}; + +export const quickStats: QuickStat[] = [ + { label: "Next due", value: `${daysUntil(currentAssignment.assignment.due_date)}d` }, + { label: "Last active", value: formatLastSeen(latestActivityTime) }, +]; + +export { assignmentCards }; + +export const assignmentFocusStats: AssignmentFocusStat[] = [ + { label: "Live now", value: `${pendingAssignments.length}` }, + { label: "Completed", value: `${submittedAssignments.length}` }, + { label: "Average score", value: formatPercent(overallAccuracy) }, + { label: "Next due", value: formatDueDate(currentAssignment.assignment.due_date) }, +]; + +export const progressFocusStats: AssignmentFocusStat[] = [ + { label: "Accuracy", value: formatPercent(overallAccuracy) }, + { label: "Marked", value: `${submittedAssignments.length}` }, + { label: "Strongest", value: strongestTopic?.label ?? "Building" }, + { label: "Study time", value: formatHours(totalDurationSeconds) }, +]; + +export const practiceFocusStats: PracticeFocusStat[] = [ + { label: "Focus topic", value: weakestTopic?.label ?? "Mixed skills" }, + { label: "Topic score", value: weakestTopic ? formatPercent(weakestTopic.value) : "--" }, + { label: "Best support", value: topSolveMode?.label ?? "Try mixed modes" }, + { label: "Ready now", value: currentAssignment.assignment.name.split("—")[0].trim() }, +]; + +export const practiceFocusCards: PracticeFocusCard[] = [ + { + title: `Rebuild ${weakestTopic?.label ?? "your focus topic"}`, + description: `Start with the topic that is costing you the most marks right now, then return to your current assignment while the method is still fresh.`, + meta: weakestTopic ? `${weakestTopic.total} recent questions · ${weakestTopic.value}% accuracy` : "Short, focused practice is the fastest win.", + tone: "yellow", + primaryLabel: "Open practice", + primaryHref: `/assignment/${currentAssignment.assignment.id}/work`, + secondaryLabel: "View progress", + secondaryHref: "/dashboard/progress", + }, + { + title: "Try one independent pass", + description: topSolveMode + ? `${topSolveMode.label} is your default. Try answering one question solo before switching modes so you build stronger recall.` + : "Use one question to test yourself first, then bring in guided help if you need it.", + meta: `Current assignment: ${currentAssignment.assignment.name}`, + tone: "blue", + primaryLabel: currentAssignment.status === "IN_PROGRESS" ? "Resume work" : "Start assignment", + primaryHref: `/assignment/${currentAssignment.assignment.id}/work`, + secondaryLabel: "Review task", + secondaryHref: `/assignment/${currentAssignment.assignment.id}`, + }, + { + title: `Finish with ${strongestTopic?.label ?? "a strong topic"}`, + description: `Once the hard bit is done, finish your session on something you usually get right so your next study block starts with confidence.`, + meta: strongestTopic ? `${strongestTopic.value}% accuracy in ${strongestTopic.label}` : "Pick a familiar topic for a strong finish.", + tone: "teal", + primaryLabel: "See assignments", + primaryHref: "/dashboard/assignments", + secondaryLabel: "Open dashboard", + secondaryHref: "/dashboard", + }, +]; + +export const messageFocusStats: MessageFocusStat[] = [ + { label: "Tutor", value: dataset.tutor.fullname.split(" ")[0] }, + { label: "Unread", value: "3" }, + { label: "Latest topic", value: weakestTopic?.label ?? "Mixed skills" }, + { label: "Last active", value: formatLastSeen(latestActivityTime) }, +]; + +export const messageFocusThreads: MessageFocusThread[] = [ + { + sender: dataset.tutor.fullname, + role: "Tutor", + initials: initialsFor(dataset.tutor.fullname), + preview: `Let’s spend the next session tightening up ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}. You only need a couple of calm wins here to lift your confidence.`, + timestamp: "Today", + unread: true, + actionLabel: "Open practice", + actionHref: "/dashboard/practice", + }, + { + sender: "Assignment check-in", + role: currentAssignment.assignment.name.split("—")[0].trim(), + initials: currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(), + preview: currentAssignment.status === "SUBMITTED" + ? `Nice work finishing this one. Review the marked questions before you start the next task.` + : `${currentAssignment.answerCount} questions are already done. Finish this assignment before ${formatDueDate(currentAssignment.assignment.due_date)} while it still feels familiar.`, + timestamp: currentAssignment.status === "SUBMITTED" ? "Yesterday" : `${daysUntil(currentAssignment.assignment.due_date)}d left`, + actionLabel: currentAssignment.status === "SUBMITTED" ? "Open review" : "Resume work", + actionHref: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`, + }, + { + sender: "Study coach", + role: "Quick reminder", + initials: "SC", + preview: `Your strongest topic is ${strongestTopic?.label ?? "building"}. End today with one easier question there after you practise ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}.`, + timestamp: "2 days ago", + actionLabel: "View progress", + actionHref: "/dashboard/progress", + }, +]; + +export const topbarMessages = messageFocusThreads.slice(0, 3); + +export const settingsFocusStats: SettingsFocusStat[] = [ + { label: "Student", value: firstName }, + { label: "Class", value: `Year ${dataset.classroom.target_level}` }, + { label: "Focus", value: weakestTopic?.label ?? "Mixed skills" }, + { label: "Goal", value: `${Math.max(Math.round(overallAccuracy) + 10, 70)}%` }, +]; + +export const settingsFocusPanels: SettingsFocusPanel[] = [ + { + title: "Profile", + description: "The learner details and classroom context this dashboard is currently designed around.", + rows: [ + { label: "Name", value: student.fullname }, + { label: "Role", value: `${dataset.classroom.name} · Student` }, + { label: "Tutor", value: dataset.tutor.fullname }, + { label: "Invite code", value: dataset.classroom.invite_code }, + ], + primaryLabel: "Open messages", + primaryHref: "/dashboard/messages", + secondaryLabel: "View progress", + secondaryHref: "/dashboard/progress", + }, + { + title: "Learning preferences", + description: "A few smart defaults for how this learner is currently working best.", + rows: [ + { label: "Best support mode", value: topSolveMode?.label ?? "Mixed support" }, + { label: "Strongest topic", value: strongestTopic?.label ?? "Building confidence" }, + { label: "Needs attention", value: weakestTopic?.label ?? "Mixed skills" }, + { label: "Last active", value: formatLastSeen(latestActivityTime) }, + ], + primaryLabel: "Open practice", + primaryHref: "/dashboard/practice", + secondaryLabel: "See assignments", + secondaryHref: "/dashboard/assignments", + }, + { + title: "Goals and reminders", + description: "Settings-like controls can start by summarising the targets and nudges this learner should keep in view.", + rows: [ + { label: "Current goal", value: `Lift ${weakestTopic?.label?.toLowerCase() ?? "focus work"} by 10%` }, + { label: "Live assignments", value: `${pendingAssignments.length}` }, + { label: "Study time", value: formatHours(totalDurationSeconds) }, + { label: "Next due", value: formatDueDate(currentAssignment.assignment.due_date) }, + ], + primaryLabel: "Open today’s plan", + primaryHref: `/assignment/${currentAssignment.assignment.id}/work`, + secondaryLabel: "Go to dashboard", + secondaryHref: "/dashboard", + }, +]; + +const mapAssignmentFocusItem = (assignment: StudentAssignment): AssignmentFocusItem => { + const isSubmitted = assignment.status === "SUBMITTED"; + const isInProgress = assignment.status === "IN_PROGRESS"; + + return { + title: assignment.assignment.name, + meta: assignment.assignment.topic, + subMeta: isSubmitted + ? `Marked ${assignment.total_marks}/${assignment.assignment.maximum_marks} · Reviewed ${assignment.answerCount} questions` + : `Due ${formatDueDate(assignment.assignment.due_date)} · ${assignment.answerCount} questions touched so far`, + statusLabel: isSubmitted ? "Completed" : isInProgress ? "In progress" : "Ready to start", + progressText: isSubmitted ? `${assignment.accuracy}% accuracy` : `${daysUntil(assignment.assignment.due_date)}d left`, + tone: isSubmitted ? "teal" : isInProgress ? "blue" : "yellow", + primaryLabel: isSubmitted ? "Open review" : isInProgress ? "Resume work" : "Start assignment", + primaryHref: isSubmitted ? `/assignment/${assignment.assignment.id}` : `/assignment/${assignment.assignment.id}/work`, + secondaryLabel: isSubmitted ? "Open workspace" : "See review", + secondaryHref: isSubmitted ? `/assignment/${assignment.assignment.id}/work` : `/assignment/${assignment.assignment.id}`, + }; +}; + +export const assignmentFocusGroups: AssignmentFocusGroup[] = [ + { + title: "Continue now", + description: "Assignments that already have momentum and are best finished next.", + items: studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS").map(mapAssignmentFocusItem), + }, + { + title: "Coming up", + description: "Work that is live but not started yet, so you can choose what to begin early.", + items: studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED").map(mapAssignmentFocusItem), + }, + { + title: "Completed", + description: "Marked work you can revisit for review, correction, and confidence boosts.", + items: studentAssignments.filter((assignment) => assignment.status === "SUBMITTED").map(mapAssignmentFocusItem), + }, +].filter((group) => group.items.length > 0); + +export const activitySummary = { + title: "Recent results", + note: `${submittedAssignments.length} assignments have been marked so far. Use this as a quick check on what is improving and what still needs another pass.`, + badge: `${submittedAssignments.length} marked`, +}; + +export const progressPoints = submittedTrend.map((point) => point.value); +export const progressLabels = submittedTrend.map((point) => point.label); + +export const highlightCards: HighlightCard[] = [ + { + value: `${studentAnswers.length}`, + label: "Questions answered", + note: `You have already submitted ${submittedAssignments.length} assignments and still have ${pendingAssignments.length} live tasks to work through.`, + tone: "yellow", + }, + { + value: formatHours(totalDurationSeconds), + label: "Time invested", + note: `Last active ${formatLastSeen(latestActivityTime)}. ${activePersona.recommendation}`, + tone: "pink", + }, +]; + +export const studentSupportList: StudentSupportCard[] = [ + { + name: `Review ${weakestTopic?.label ?? "your focus topic"}`, + meta: `${weakestTopic?.value ?? 0}% accuracy across ${weakestTopic?.total ?? 0} questions. A short refresher here should give you the fastest win.`, + initials: weakestTopic?.label.slice(0, 2).toUpperCase() ?? "WK", + actionLabel: "Start practice", + href: `/assignment/${currentAssignment.assignment.id}/work`, + }, + { + name: `Finish ${currentAssignment.assignment.name.split("—")[0].trim()}`, + meta: + currentAssignment.status === "IN_PROGRESS" + ? `${currentAssignment.answerCount} questions are already done. One more focused block should move this over the line.` + : `This is your next live assignment. Starting early will make it feel much lighter later in the week.`, + initials: currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(), + actionLabel: currentAssignment.status === "IN_PROGRESS" ? "Resume" : "Start", + href: `/assignment/${currentAssignment.assignment.id}/work`, + }, + { + name: "Try one question solo", + meta: topSolveMode + ? `${topSolveMode.label} is your default mode. Try one independent attempt before asking for help on the next tricky question.` + : "Try guided support on tougher questions, then return to independent practice.", + initials: "IP", + actionLabel: "Use this tip", + href: `/assignment/${currentAssignment.assignment.id}/work`, + }, +]; + +export { topicMasteryBars }; + +export const overallPassRate = Math.round(overallAccuracy); + +export { solveModeUsage, usageSummary }; diff --git a/Frontend/src/context/theme/context.tsx b/Frontend/src/context/theme/context.tsx new file mode 100644 index 0000000..d4e42af --- /dev/null +++ b/Frontend/src/context/theme/context.tsx @@ -0,0 +1,55 @@ +import { createContext, createSignal, onMount, useContext, type ParentComponent } from "solid-js"; + +type ThemeMode = "light" | "dark"; + +type ThemeContextType = { + mode: () => ThemeMode; + toggleMode: () => void; + isReady: () => boolean; +}; + +const THEME_STORAGE_KEY = "boostai-color-mode"; +const ThemeContext = createContext(); + +const getInitialMode = (): ThemeMode => { + if (typeof window === "undefined") return "light"; + + const storedMode = window.localStorage.getItem(THEME_STORAGE_KEY); + if (storedMode === "light" || storedMode === "dark") return storedMode; + + const attributeMode = document.documentElement.getAttribute("data-color-scheme"); + if (attributeMode === "light" || attributeMode === "dark") return attributeMode; + + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +export const ThemeProvider: ParentComponent = (props) => { + const [mode, setMode] = createSignal("light"); + const [isReady, setIsReady] = createSignal(false); + + const applyMode = (nextMode: ThemeMode) => { + setMode(nextMode); + document.documentElement.setAttribute("data-color-scheme", nextMode); + window.localStorage.setItem(THEME_STORAGE_KEY, nextMode); + }; + + const toggleMode = () => { + applyMode(mode() === "light" ? "dark" : "light"); + }; + + onMount(() => { + applyMode(getInitialMode()); + setIsReady(true); + }); + + return {props.children}; +}; + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within ThemeProvider"); + } + + return context; +}; diff --git a/Frontend/src/entry-server.tsx b/Frontend/src/entry-server.tsx index e8e927b..37f6b7b 100644 --- a/Frontend/src/entry-server.tsx +++ b/Frontend/src/entry-server.tsx @@ -1,4 +1,4 @@ -// Path: src/entry-server.tsx +// Path: Frontend/src/entry-server.tsx // @refresh reload import { createHandler, StartServer } from "@solidjs/start/server"; @@ -12,6 +12,9 @@ export default createHandler(() => ( {assets} + + +
{children}
diff --git a/Frontend/src/routes/assignment/[id].tsx b/Frontend/src/routes/assignment/[id].tsx new file mode 100644 index 0000000..cb41164 --- /dev/null +++ b/Frontend/src/routes/assignment/[id].tsx @@ -0,0 +1,9 @@ +// Path: Frontend/src/routes/assignment/[id].tsx + +import type { ParentComponent } from "solid-js"; + +const AssignmentRouteLayout: ParentComponent = (props) => { + return <>{props.children}; +}; + +export default AssignmentRouteLayout; diff --git a/Frontend/src/routes/assignment/[id]/assignment-work.module.scss b/Frontend/src/routes/assignment/[id]/assignment-work.module.scss new file mode 100644 index 0000000..eaeda1b --- /dev/null +++ b/Frontend/src/routes/assignment/[id]/assignment-work.module.scss @@ -0,0 +1,398 @@ +.page { + min-height: 100dvh; + padding: 1.25rem; + background: + radial-gradient(circle at top left, var(--page-glow-primary), transparent 30%), + radial-gradient(circle at bottom right, var(--page-glow-secondary), transparent 28%), + linear-gradient(135deg, var(--page-gradient-start), var(--page-gradient-end)); +} + +.shell { + width: min(100%, 96rem); + margin: 0 auto; + display: grid; + gap: 1.25rem; +} + +.submitBanner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1.1rem 1.25rem; + border-radius: 1.35rem; + background: var(--surface-success); + border: 1px solid color-mix(in srgb, var(--success) 28%, transparent); + box-shadow: var(--shadow-soft); + + p:last-child { + color: var(--text-muted); + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + } +} + +.bannerEyebrow, +.eyebrow, +.emptyEyebrow, +.helperEyebrow, +.questionEyebrow { + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.76rem; + color: var(--text-muted); +} + +.bannerEyebrow { + color: var(--success); + font-weight: 600; +} + +.bannerLink { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.85rem 1rem; + border-radius: 9999px; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + color: var(--text); + text-decoration: none; + font-weight: 600; +} + +.headerCard { + display: grid; + gap: 1rem; + padding: 1.4rem; + border-radius: 1.5rem; + background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end)); + border: 1px solid var(--border-overlay); + box-shadow: var(--shadow-elevated); + color: var(--text-on-accent); +} + +.headerTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.backLink, +.statusPill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 0.8rem; + border-radius: 9999px; + background: var(--surface-overlay-soft); + border: 1px solid var(--border-overlay); + color: var(--text-on-accent); + font-size: 0.85rem; + text-decoration: none; +} + +.headerCopy { + display: grid; + gap: 0.4rem; + + h1 { + font-size: clamp(2rem, 1.55rem + 1.3vw, 3rem); + line-height: 1; + letter-spacing: -0.04em; + } + + p { + max-width: 60ch; + } +} + +.eyebrow { + color: var(--text-on-accent-muted); +} + +.headerMeta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.saveState { + font-size: 0.92rem; + font-weight: 500; + color: var(--text-on-accent-muted); +} + +.submitButton, +.primaryButton, +.secondaryButton, +.backButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.9rem 1rem; + border-radius: 1rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; +} + +.submitButton, +.backButton, +.primaryButton { + border: none; + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + box-shadow: var(--action-primary-shadow); + + &:hover { + transform: translateY(-1px); + box-shadow: var(--action-primary-shadow-hover); + } +} + +.contentGrid { + display: grid; + gap: 1.25rem; + + @media (min-width: 1080px) { + grid-template-columns: minmax(15rem, 18rem) minmax(0, 1fr); + align-items: start; + } +} + +.navigator, +.workspace, +.emptyState { + display: grid; + gap: 1rem; + padding: 1.2rem; + border-radius: 1.35rem; + background: var(--surface-panel); + border: 1px solid var(--border-soft); + box-shadow: var(--shadow-soft); +} + +.navigator { + @media (min-width: 1080px) { + position: sticky; + top: 1.25rem; + } +} + +.navigatorHeader, +.workspaceHeader { + display: grid; + gap: 0.2rem; + + h2 { + font-size: 1.2rem; + line-height: 1.12; + letter-spacing: -0.03em; + } + + p { + color: var(--text-muted); + font-size: 0.9rem; + } +} + +.progressMeta { + display: flex; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.8rem 0.9rem; + border-radius: 1rem; + background: var(--surface-soft); + border: 1px solid var(--border-soft); + font-size: 0.85rem; + color: var(--text-muted); +} + +.questionButtons { + display: grid; + gap: 0.65rem; +} + +.questionButton { + display: grid; + gap: 0.15rem; + padding: 0.85rem 0.95rem; + text-align: left; + border-radius: 1rem; + border: 1px solid var(--border-divider); + background: var(--surface-panel-strong); + cursor: pointer; + transition: + transform 0.2s ease, + border-color 0.2s ease, + background-color 0.2s ease; + + &:hover { + transform: translateY(-1px); + } + + span { + font-weight: 600; + } + + small { + color: var(--text-muted); + font-size: 0.82rem; + } +} + +.activeQuestion { + border-color: var(--info); + background: var(--surface-info); +} + +.completedQuestion { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--success) 30%, transparent); +} + +.questionMeta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + span { + padding: 0.35rem 0.6rem; + border-radius: 9999px; + background: var(--surface-panel-strong); + border: 1px solid var(--border-divider); + font-size: 0.82rem; + color: var(--text-muted); + } +} + +.questionStatus { + margin-top: 0.4rem; + font-size: 0.92rem; + font-weight: 500; + color: var(--info); +} + +.fieldGroup { + display: grid; + gap: 0.45rem; + + span { + font-size: 0.88rem; + font-weight: 500; + } + + input, + textarea, + select { + width: 100%; + padding: 0.9rem 1rem; + border-radius: 1rem; + border: 1px solid var(--border-field); + background: var(--surface-field); + outline: none; + color: var(--text); + + &:focus { + border-color: var(--primary); + box-shadow: 0 0 0 4px var(--focus-ring-primary); + background: var(--surface-field-focus); + } + } + + textarea { + resize: vertical; + min-height: 10rem; + } +} + +.fieldHint { + font-size: 0.82rem; + color: var(--text-muted); +} + +.helperCard { + display: grid; + gap: 0.45rem; + padding: 1rem; + border-radius: 1rem; + background: var(--surface-soft); + border: 1px solid var(--border-soft); + + p:last-of-type { + color: var(--text-muted); + } +} + +.answerReveal { + display: grid; + gap: 0.15rem; + margin-top: 0.3rem; + + span { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + } + + strong { + font-size: 1rem; + } +} + +.actionRow { + display: flex; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.primaryActions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.secondaryButton { + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + color: var(--text); + + &:hover { + transform: translateY(-1px); + background: var(--surface-soft); + } +} + +.questionFooter { + display: flex; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.85rem; + color: var(--text-muted); +} + +.emptyState { + h1 { + font-size: clamp(2rem, 1.7rem + 1vw, 2.8rem); + line-height: 1; + letter-spacing: -0.04em; + } + + p { + color: var(--text-muted); + } +} diff --git a/Frontend/src/routes/assignment/[id]/index.tsx b/Frontend/src/routes/assignment/[id]/index.tsx new file mode 100644 index 0000000..6bb5757 --- /dev/null +++ b/Frontend/src/routes/assignment/[id]/index.tsx @@ -0,0 +1,48 @@ +import { A, useParams } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { createMemo } from "solid-js"; +import AssignmentHeader from "../../../components/assignment/assignment-header"; +import AssignmentOverview from "../../../components/assignment/assignment-overview"; +import AssignmentQuestionList from "../../../components/assignment/assignment-question-list"; +import AssignmentTabs from "../../../components/assignment/assignment-tabs"; +import { getAssignmentPageData } from "../../../components/assignment/assignment.data"; +import styles from "../assignment-page.module.scss"; + +const AssignmentPage: Component = () => { + const params = useParams(); + const assignmentData = createMemo(() => getAssignmentPageData(Number(params.id))); + + return ( +
+
+ {assignmentData() ? ( + <> + + + +
+
+ +
+ + +
+ + ) : ( +
+

Assignment not found

+

We could not find that sample assignment.

+

Try one of the mock IDs like 3006 or 3007, or jump back to the dashboard.

+ + Back to dashboard + +
+ )} +
+
+ ); +}; + +export default AssignmentPage; diff --git a/Frontend/src/routes/assignment/[id]/work.tsx b/Frontend/src/routes/assignment/[id]/work.tsx new file mode 100644 index 0000000..023e311 --- /dev/null +++ b/Frontend/src/routes/assignment/[id]/work.tsx @@ -0,0 +1,382 @@ +import { A, useParams } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import { getAssignmentPageData } from "../../../components/assignment/assignment.data"; +import AssignmentTabs from "../../../components/assignment/assignment-tabs"; +import styles from "./assignment-work.module.scss"; + +type DraftState = Record< + number, + { + answer: string; + steps: string; + solveMode: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; + } +>; + +type PersistedWorkspaceState = { + drafts: DraftState; + activeQuestionId: number | null; + isSubmitted: boolean; + submittedAt: string | null; + lastSavedAt: string | null; +}; + +const solveModeOptions = [ + { value: "just_answer", label: "Just answer" }, + { value: "step_by_step", label: "Step by step" }, + { value: "solve_together", label: "Solve together" }, + { value: "handwritten", label: "Handwritten" }, +] as const; + +const isClient = typeof window !== "undefined"; + +const AssignmentWorkPage: Component = () => { + const params = useParams(); + const assignmentData = createMemo(() => getAssignmentPageData(Number(params.id))); + const storageKey = createMemo(() => `boostai-assignment-work-${params.id}`); + + const initialDrafts = createMemo(() => { + const data = assignmentData(); + if (!data) return {}; + + return Object.fromEntries( + data.questions.map((question) => [ + question.id, + { + answer: question.initialAnswer ?? "", + steps: "", + solveMode: question.initialSolveMode ?? "just_answer", + }, + ]), + ) as DraftState; + }); + + const [drafts, setDrafts] = createSignal({}); + const [activeQuestionId, setActiveQuestionId] = createSignal(null); + const [saveState, setSaveState] = createSignal<"idle" | "saving" | "saved">("idle"); + const [lastSavedAt, setLastSavedAt] = createSignal(null); + const [isSubmitted, setIsSubmitted] = createSignal(false); + const [submittedAt, setSubmittedAt] = createSignal(null); + + const readPersistedState = (): PersistedWorkspaceState | null => { + if (!isClient) return null; + + const raw = window.localStorage.getItem(storageKey()); + if (!raw) return null; + + try { + return JSON.parse(raw) as PersistedWorkspaceState; + } catch { + return null; + } + }; + + const writePersistedState = (overrideSavedAt?: string) => { + if (!isClient || !assignmentData()) return; + + const savedAt = overrideSavedAt ?? new Date().toISOString(); + window.localStorage.setItem( + storageKey(), + JSON.stringify({ + drafts: drafts(), + activeQuestionId: activeQuestionId(), + isSubmitted: isSubmitted(), + submittedAt: submittedAt(), + lastSavedAt: savedAt, + }), + ); + setLastSavedAt(savedAt); + setSaveState("saved"); + }; + + createEffect(() => { + const data = assignmentData(); + if (!data) return; + + const persisted = readPersistedState(); + setDrafts({ + ...initialDrafts(), + ...(persisted?.drafts ?? {}), + }); + setActiveQuestionId(persisted?.activeQuestionId ?? data.questions[0]?.id ?? null); + setIsSubmitted(persisted?.isSubmitted ?? false); + setSubmittedAt(persisted?.submittedAt ?? null); + setLastSavedAt(persisted?.lastSavedAt ?? null); + setSaveState("idle"); + }); + + const questions = createMemo(() => assignmentData()?.questions ?? []); + const currentQuestion = createMemo(() => questions().find((question) => question.id === activeQuestionId()) ?? questions()[0]); + const activeDraft = createMemo(() => (currentQuestion() ? drafts()[currentQuestion()!.id] : undefined)); + const answeredCount = createMemo(() => Object.values(drafts()).filter((draft) => draft.answer.trim().length > 0).length); + const remainingCount = createMemo(() => Math.max(questions().length - answeredCount(), 0)); + const isLastQuestion = createMemo(() => { + const current = currentQuestion(); + if (!current) return false; + return questions()[questions().length - 1]?.id === current.id; + }); + + const saveLabel = createMemo(() => { + if (saveState() === "saving") return "Saving draft..."; + if (saveState() === "saved" && lastSavedAt()) { + return `Saved ${new Intl.DateTimeFormat("en-GB", { + hour: "numeric", + minute: "2-digit", + }).format(new Date(lastSavedAt()!))}`; + } + + return "Draft stored on this device"; + }); + + let saveTimer: ReturnType | undefined; + + createEffect(() => { + if (!isClient || !assignmentData()) return; + + drafts(); + activeQuestionId(); + isSubmitted(); + submittedAt(); + + setSaveState("saving"); + if (saveTimer) clearTimeout(saveTimer); + + saveTimer = setTimeout(() => { + writePersistedState(); + }, 350); + }); + + onCleanup(() => { + if (saveTimer) clearTimeout(saveTimer); + }); + + const updateDraft = (field: "answer" | "steps" | "solveMode", value: string) => { + const question = currentQuestion(); + if (!question) return; + + setDrafts((current) => ({ + ...current, + [question.id]: { + ...current[question.id], + [field]: value, + }, + })); + setIsSubmitted(false); + }; + + const saveNow = () => { + if (saveTimer) clearTimeout(saveTimer); + writePersistedState(); + }; + + const moveQuestion = (direction: 1 | -1) => { + const current = currentQuestion(); + if (!current) return; + const index = questions().findIndex((question) => question.id === current.id); + const target = questions()[index + direction]; + if (target) setActiveQuestionId(target.id); + }; + + const handleSaveAndContinue = () => { + saveNow(); + if (!isLastQuestion()) moveQuestion(1); + }; + + const handleSubmit = () => { + const savedAt = new Date().toISOString(); + setIsSubmitted(true); + setSubmittedAt(savedAt); + writePersistedState(savedAt); + }; + + return ( +
+
+ +

Assignment not found

+

We could not find that assignment workspace.

+ + Back to dashboard + + + } + > + {(data) => ( + <> + +
+
+

Practice submitted

+

+ You answered {answeredCount()} of {data().questions.length} questions. +

+

Your latest draft is saved locally. You can review the assignment or keep refining any answer.

+
+ + Open review page + +
+
+ +
+
+ + Back to review + + {data().statusLabel} +
+ +
+

{data().classroomName}

+

Work through {data().title}

+

Answer each question, show your working, and move at a steady pace. Your draft autosaves on this device while you work.

+
+ +
+

{saveLabel()}

+ +
+
+ + + +
+ + +
+ + {() => ( + <> +
+
+

Question {currentQuestion()!.order}

+

{currentQuestion()!.prompt}

+

{currentQuestion()!.statusLabel}

+
+
+ {currentQuestion()!.topic} + + {currentQuestion()!.subTopic} + + {currentQuestion()!.difficulty} +
+
+ + + + + +