vibe coded frontend

This commit is contained in:
MangoPig
2026-05-12 10:34:42 +01:00
parent 0fdec18a15
commit 675285e99d
99 changed files with 57409 additions and 29 deletions

13
Frontend/README.md Normal file
View File

@@ -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

View File

@@ -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"
}
}

348
Frontend/pnpm-lock.yaml generated
View File

@@ -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: {}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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 (
<main>
<h1>AI</h1>
</main>
<Suspense>
<div classList={{
"app-route-shell": true,
"app-route-shell-viewport-locked": isViewportLockedRoute(),
}}>
<Transition name="page-fade-slide">
<Show when={location.pathname} keyed>
{(path) => (
<div class="page-transition-stage" data-route={path}>
{props.children}
</div>
)}
</Show>
</Transition>
</div>
</Suspense>
);
};
export default function App() {
return (
<ThemeProvider>
<Router root={AppRoot}>
<FileRoutes />
</Router>
</ThemeProvider>
);
}

View File

@@ -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;
}

View File

@@ -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> = (props) => {
return (
<section class={styles.headerCard}>
<div class={styles.headerTop}>
<A href="/dashboard" class={styles.backLink}>
Back to dashboard
</A>
<span class={styles.statusPill}>{props.data.statusLabel}</span>
</div>
<div class={styles.copy}>
<p class={styles.eyebrow}>{props.data.classroomName}</p>
<h1>{props.data.title}</h1>
<p>{props.data.headline}</p>
<p class={styles.description}>{props.data.description}</p>
</div>
</section>
);
};
export default AssignmentHeader;

View File

@@ -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;
}

View File

@@ -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> = (props) => {
return (
<div class={styles.stack}>
<section class={styles.panel}>
<div class={styles.cardHeader}>
<h2>Assignment overview</h2>
<p>{props.data.studentName}</p>
</div>
<div class={styles.statGrid}>
<For each={props.data.stats}>
{(stat) => (
<div class={styles.statCard}>
<span>{stat.label}</span>
<strong>{stat.value}</strong>
</div>
)}
</For>
</div>
</section>
<section class={styles.panel}>
<div class={styles.cardHeader}>
<h2>{props.data.coachCard.title}</h2>
<p>{props.data.tutorName}</p>
</div>
<p class={styles.coachCopy}>{props.data.coachCard.description}</p>
<ul class={styles.tipList}>
<For each={props.data.coachCard.items}>{(item) => <li>{item}</li>}</For>
</ul>
<A href={props.data.primaryHref} class={styles.primaryButton}>
{props.data.primaryAction}
</A>
</section>
</div>
);
};
export default AssignmentOverview;

View File

@@ -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);
}
}

View File

@@ -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> = (props) => {
return (
<section class={styles.section}>
<div class={styles.header}>
<h2>Sample questions</h2>
<p>{props.data.questions.length} loaded from the mock dataset</p>
</div>
<div class={styles.list}>
<For each={props.data.questions}>
{(question) => (
<article class={styles.card}>
<div class={styles.cardTop}>
<div>
<p class={styles.order}>Question {question.order}</p>
<h3>{question.prompt}</h3>
</div>
<span classList={{ [styles.statusPill]: true, [styles[question.statusTone]]: true }}>{question.statusLabel}</span>
</div>
<div class={styles.metaRow}>
<span>{question.topic}</span>
<Show when={question.subTopic}>
<span>{question.subTopic}</span>
</Show>
<span>{question.difficulty}</span>
<span>{question.marks} mark</span>
<Show when={question.solveModeLabel}>
<span>{question.solveModeLabel}</span>
</Show>
</div>
<div class={styles.responseBlock}>
<p>{question.responseLabel}</p>
<strong>{question.responseValue}</strong>
<span>{question.feedback}</span>
</div>
<Show when={question.showAnswerKey}>
<div class={styles.answerKey}>
<p>Answer key</p>
<strong>{question.correctAnswer}</strong>
</div>
</Show>
</article>
)}
</For>
</div>
</section>
);
};
export default AssignmentQuestionList;

View File

@@ -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 (
<nav class={styles.tabs} aria-label="Assignment views">
<div class={styles.tabList}>
<A href={reviewHref()} class={`${styles.tabLink} ${!isWork() ? styles.tabLinkActive : ""}`.trim()}>
Review
</A>
<A href={workHref()} class={`${styles.tabLink} ${isWork() ? styles.tabLinkActive : ""}`.trim()}>
Work
</A>
</div>
</nav>
);
};
export default AssignmentTabs;

View File

@@ -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,
};
};

View File

@@ -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);
}

View File

@@ -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 (
<section class={styles.section}>
<div class={styles.header}>
<h2>Your progress</h2>
<A href="/dashboard/progress">Open progress</A>
</div>
<article class={styles.progressCard}>
<div class={styles.progressHeader}>
<div>
<h3>{activitySummary.title}</h3>
<p>{activitySummary.note}</p>
</div>
<div class={styles.progressBadge}>{activitySummary.badge}</div>
</div>
<svg viewBox="0 0 270 140" class={styles.chart} role="img" aria-label="My progress line chart">
<defs>
<linearGradient id="progress-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="var(--blue-300)" stop-opacity="0.55" />
<stop offset="100%" stop-color="var(--blue-300)" stop-opacity="0.05" />
</linearGradient>
</defs>
<line x1="0" y1="120" x2="270" y2="120" class={styles.axis} />
<polyline points={`0,120 ${points} 270,120`} class={styles.area} />
<polyline points={points} class={styles.line} />
</svg>
<div class={styles.monthRow}>
<For each={progressLabels}>{(label) => <span>{label}</span>}</For>
</div>
</article>
<div class={styles.highlightGrid}>
<For each={highlightCards}>
{(card) => (
<article classList={{ [styles.highlightCard]: true, [styles[card.tone]]: true }}>
<strong>{card.value}</strong>
<h3>{card.label}</h3>
<p>{card.note}</p>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardActivity;

View File

@@ -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);
}

View File

@@ -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 (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Assignments</p>
<h1>Your assignment hub</h1>
<p>Stay in dashboard mode to see what is live, what needs finishing, and what is ready for review.</p>
</div>
<div class={styles.statGrid}>
<For each={assignmentFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.groupList}>
<For each={assignmentFocusGroups}>
{(group) => (
<section class={styles.group}>
<div class={styles.groupHeader}>
<div>
<h2>{group.title}</h2>
<p>{group.description}</p>
</div>
<span class={styles.groupCount}>{group.items.length}</span>
</div>
<div class={styles.cardGrid}>
<For each={group.items}>
{(item) => (
<article class={styles.assignmentCard}>
<div class={styles.cardTop}>
<span classList={{ [styles.statusChip]: true, [styles[item.tone]]: true }}>{item.statusLabel}</span>
<span class={styles.progressText}>{item.progressText}</span>
</div>
<div class={styles.cardBody}>
<h3>{item.title}</h3>
<p>{item.meta}</p>
<small>{item.subMeta}</small>
</div>
<div class={styles.cardActions}>
<A href={item.primaryHref} class={styles.primaryAction}>
{item.primaryLabel}
</A>
<A href={item.secondaryHref} class={styles.secondaryAction}>
{item.secondaryLabel}
</A>
</div>
</article>
)}
</For>
</div>
</section>
)}
</For>
</div>
</section>
);
};
export default DashboardAssignmentsFocus;

View File

@@ -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);
}
}

View File

@@ -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 (
<section class={styles.section}>
<div class={styles.header}>
<h2>Keep going</h2>
<A href="/dashboard/assignments">See all</A>
</div>
<div class={styles.courseList}>
<For each={assignmentCards}>
{(assignment) => (
<article class={styles.courseCard}>
<div classList={{ [styles.badge]: true, [styles[assignment.accent]]: true }}>{assignment.title.split("—")[0].trim().replace("HW", "H")}</div>
<div class={styles.courseBody}>
<h3>{assignment.title}</h3>
<p>{assignment.lessons}</p>
</div>
<A href={assignment.href} class={styles.ctaLink}>
{assignment.cta}
</A>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardCourses;

View File

@@ -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;
}
}

View File

@@ -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 (
<section class={styles.heroGrid}>
<div class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>{heroSummary.eyebrow}</p>
<h1>{heroSummary.title}</h1>
<p>{heroSummary.description}</p>
<div class={styles.metricRow}>
<For each={spotlightStats}>
{(stat) => (
<div class={styles.metricPill}>
<span classList={{ [styles.metricIcon]: true, [styles[stat.tone]]: true }}>{stat.label.slice(0, 1)}</span>
<div>
<p>{stat.label}</p>
<strong>{stat.value}</strong>
</div>
</div>
)}
</For>
</div>
</div>
<div class={styles.heroVisual} aria-hidden="true">
<div class={styles.orbit}></div>
<For each={heroSummary.visualBadges}>{(badge) => <div class={styles.visualCard}>{badge}</div>}</For>
</div>
</div>
<div class={styles.sideCard}>
<h2>{heroSideCard.title}</h2>
<p>{heroSideCard.description}</p>
<A href={heroSideCard.buttonHref} class={styles.sideCardAction}>
{heroSideCard.buttonLabel}
</A>
<div class={styles.quickStats}>
<For each={quickStats}>
{(stat) => (
<div class={styles.statCard}>
<p>{stat.label}</p>
<strong>{stat.value}</strong>
</div>
)}
</For>
</div>
</div>
</section>
);
};
export default DashboardHero;

View File

@@ -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;
}

View File

@@ -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 (
<section class={styles.grid}>
<article class={styles.panel}>
<h2>My topic mastery</h2>
<div class={styles.barChart}>
<For each={topicMasteryBars}>
{(item) => (
<div class={styles.barRow}>
<div class={styles.barMeta}>
<span classList={{ [styles.dot]: true, [styles[item.tone]]: true }}></span>
<span>{item.label}</span>
</div>
<div class={styles.track}>
<div classList={{ [styles.fill]: true, [styles[item.tone]]: true }} style={{ width: `${item.value}%` }}></div>
</div>
</div>
)}
</For>
</div>
</article>
<article class={styles.panel}>
<h2>My overall accuracy</h2>
<div class={styles.donutWrap}>
<div class={styles.donut} style={{ background: `conic-gradient(var(--purple-400) 0 ${Math.max(0, overallPassRate - 18)}%, var(--blue-400) ${Math.max(0, overallPassRate - 18)}% ${overallPassRate}%, var(--surface-soft) ${overallPassRate}% 100%)` }}></div>
<div class={styles.donutLabel}>
<strong>{overallPassRate}%</strong>
<span>Accuracy</span>
</div>
</div>
</article>
<article class={styles.panel}>
<h2>Solve mode usage</h2>
<p class={styles.usageNote}>{usageSummary.note}</p>
<div class={styles.usageList}>
<For each={solveModeUsage}>
{(item) => (
<div class={styles.usageItem}>
<div class={styles.usageMeta}>
<span>{item.label}</span>
<strong>{item.value}%</strong>
</div>
<div class={styles.track}>
<div classList={{ [styles.fill]: true, [styles[item.tone]]: true }} style={{ width: `${item.value}%` }}></div>
</div>
</div>
)}
</For>
</div>
</article>
</section>
);
};
export default DashboardInsights;

View File

@@ -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);
}
}

View File

@@ -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 (
<section class={styles.section}>
<div class={styles.header}>
<h2>Try these next</h2>
<A href="/dashboard/practice">View plan</A>
</div>
<div class={styles.list}>
<For each={studentSupportList}>
{(student) => (
<article class={styles.card}>
<div class={styles.avatar}>{student.initials}</div>
<div class={styles.copy}>
<h3>{student.name}</h3>
<p>{student.meta}</p>
</div>
<A href={student.href} class={styles.actionLink}>
{student.actionLabel}
</A>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardInstructors;

View File

@@ -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);
}
}

View File

@@ -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 (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Messages</p>
<h1>Your message centre</h1>
<p>Keep dashboard context while you check tutor guidance, assignment reminders, and quick study nudges that point you to the next useful action.</p>
</div>
<div class={styles.statGrid}>
<For each={messageFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.threadList}>
<For each={messageFocusThreads}>
{(thread) => (
<article class={styles.threadCard}>
<div class={styles.threadTop}>
<div class={styles.senderWrap}>
<span class={styles.avatar}>{thread.initials}</span>
<div>
<h2>{thread.sender}</h2>
<p>{thread.role}</p>
</div>
</div>
<div class={styles.threadMeta}>
<span>{thread.timestamp}</span>
{thread.unread && <strong class={styles.unread}>Unread</strong>}
</div>
</div>
<p class={styles.preview}>{thread.preview}</p>
<div class={styles.threadActions}>
<A href={thread.actionHref} class={styles.primaryAction}>
{thread.actionLabel}
</A>
</div>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardMessagesFocus;

View File

@@ -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);
}

View File

@@ -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 (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Practice</p>
<h1>Your practice space</h1>
<p>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.</p>
</div>
<div class={styles.statGrid}>
<For each={practiceFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.cardGrid}>
<For each={practiceFocusCards}>
{(card) => (
<article class={styles.practiceCard}>
<span classList={{ [styles.toneChip]: true, [styles[card.tone]]: true }}>{card.meta}</span>
<div class={styles.cardBody}>
<h2>{card.title}</h2>
<p>{card.description}</p>
</div>
<div class={styles.cardActions}>
<A href={card.primaryHref} class={styles.primaryAction}>
{card.primaryLabel}
</A>
<A href={card.secondaryHref} class={styles.secondaryAction}>
{card.secondaryLabel}
</A>
</div>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardPracticeFocus;

View File

@@ -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;
}
}

View File

@@ -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 (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Progress</p>
<h1>Your learning progress</h1>
<p>Stay inside the dashboard to review your results, track improvement, and spot where a small practice block could lift your score fastest.</p>
</div>
<div class={styles.statGrid}>
<For each={progressFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.contentStack}>
<DashboardActivity />
<DashboardInsights />
</div>
</section>
);
};
export default DashboardProgressFocus;

View File

@@ -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);
}
}

View File

@@ -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 (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Settings</p>
<h1>Your dashboard settings</h1>
<p>Keep this inside the dashboard shell so profile details, study preferences, and learner goals feel like part of the same student workspace.</p>
</div>
<div class={styles.profileChip}>
<span class={styles.avatar}>{topbarSummary.profileBadge}</span>
<div>
<strong>{topbarSummary.profileName}</strong>
<span>{topbarSummary.profileRole}</span>
</div>
</div>
<div class={styles.statGrid}>
<For each={settingsFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.panelGrid}>
<For each={settingsFocusPanels}>
{(panel) => (
<article class={styles.panelCard}>
<div class={styles.panelBody}>
<div class={styles.panelCopy}>
<h2>{panel.title}</h2>
<p>{panel.description}</p>
</div>
<div class={styles.rowList}>
<For each={panel.rows}>
{(row) => (
<div class={styles.rowItem}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
)}
</For>
</div>
</div>
<div class={styles.panelActions}>
<A href={panel.primaryHref} class={styles.primaryAction}>
{panel.primaryLabel}
</A>
{panel.secondaryHref && panel.secondaryLabel && (
<A href={panel.secondaryHref} class={styles.secondaryAction}>
{panel.secondaryLabel}
</A>
)}
</div>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardSettingsFocus;

View File

@@ -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<DashboardShellProps> = (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 (
<main class={styles.dashboardPage}>
<div classList={{ [styles.dashboardLayout]: true, [styles.dashboardLayoutSidebarCollapsed]: desktopSidebarCollapsed() }}>
<div class={styles.sidebarRail}>
<button
type="button"
classList={{ [styles.sidebarBackdrop]: true, [styles.sidebarBackdropVisible]: sidebarOpen() }}
aria-label="Close sidebar"
tabIndex={sidebarOpen() ? 0 : -1}
onClick={closeSidebar}
/>
<div
classList={{
[styles.sidebarPanel]: true,
[styles.sidebarPanelOpen]: sidebarOpen(),
[styles.sidebarPanelDesktopCollapsed]: desktopSidebarCollapsed(),
}}
>
<div class={styles.sidebarPanelInner}>
<DashboardSidebar onNavigate={closeSidebar} />
</div>
</div>
</div>
<section class={styles.dashboardMain}>
<DashboardTopbar isSidebarOpen={isSidebarVisible()} onMenuToggle={toggleSidebar} />
{props.children}
</section>
</div>
</main>
);
};
export default DashboardShell;

View File

@@ -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;
}
}

View File

@@ -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<DashboardSidebarProps> = (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 (
<aside class={styles.sidebar}>
<div class={styles.brand}>
<div class={styles.logoMark}>R</div>
<div>
<p class={styles.brandName}>Rooster AI</p>
<p class={styles.brandMeta}>{classroomSummary.name}</p>
</div>
</div>
<nav class={styles.navigation} aria-label="Dashboard navigation">
<For each={sidebarLinks}>
{(link) => (
<A
href={link.href ?? "#"}
classList={{ [styles.link]: true, [styles.active]: isActiveLink(link.href) || (!!link.active && location.pathname === "/dashboard") }}
onClick={() => props.onNavigate?.()}
>
<span class={styles.iconSlot}>{link.icon}</span>
<span class={styles.linkCopy}>
<strong>{link.label}</strong>
{link.detail && <small>{link.detail}</small>}
</span>
</A>
)}
</For>
</nav>
<div class={styles.supportCard}>
<div class={styles.avatarRow}>
<For each={sidebarSupport.avatars}>{(avatar) => <span>{avatar}</span>}</For>
</div>
<h2>{sidebarSupport.title}</h2>
<p>{sidebarSupport.description}</p>
<A href={sidebarSupport.buttonHref} class={styles.supportButton} onClick={() => props.onNavigate?.()}>
{sidebarSupport.buttonLabel}
</A>
</div>
</aside>
);
};
export default DashboardSidebar;

View File

@@ -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;
}
}

View File

@@ -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 (
<button type="button" aria-label="Toggle colour scheme" class={styles.toggleButton} onClick={toggleMode}>
<div class={styles.iconContainer}>
<span class={`${styles.colorIcon} ${styles.moonWrapper}`}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15.5 3.5A7.5 7.5 0 1 0 20.5 16a8 8 0 1 1-5-12.5Z" />
</svg>
</span>
<span class={`${styles.colorIcon} ${styles.sunWrapper}`}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4.5" />
<path d="M12 2.5v2.2M12 19.3v2.2M4.7 4.7l1.6 1.6M17.7 17.7l1.6 1.6M2.5 12h2.2M19.3 12h2.2M4.7 19.3l1.6-1.6M17.7 6.3l1.6-1.6" />
</svg>
</span>
</div>
</button>
);
};
export default DashboardThemeToggle;

View File

@@ -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;
}

View File

@@ -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<DashboardTopbarProps> = (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 (
<header class={styles.topbar}>
<button
type="button"
classList={{ [styles.menuButton]: true, [styles.menuButtonOpen]: props.isSidebarOpen }}
aria-label={props.isSidebarOpen ? "Close menu" : "Open menu"}
aria-expanded={props.isSidebarOpen}
onClick={props.onMenuToggle}
>
<span class={styles.menuGlyph} aria-hidden="true">
<span class={styles.menuLine} />
<span class={styles.menuLine} />
<span class={styles.menuLine} />
</span>
</button>
<label class={styles.searchField}>
<span class={styles.searchIcon} aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="11" cy="11" r="6" />
<path d="M20 20l-4.2-4.2" />
</svg>
</span>
<input type="search" placeholder={topbarSummary.searchPlaceholder} />
</label>
<div class={styles.actions} ref={menuRoot}>
<DashboardThemeToggle />
<div class={styles.menuGroup}>
<button
type="button"
class={styles.iconButton}
aria-label="Notifications"
aria-expanded={openMenu() === "notifications"}
onClick={() => toggleMenu("notifications")}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5a4 4 0 0 1 4 4v2.5c0 1 .4 2 .99 2.8L18 16H6l1.01-1.7c.59-.8.99-1.8.99-2.8V9a4 4 0 0 1 4-4Z" />
<path d="M10 18a2 2 0 0 0 4 0" />
</svg>
<Show when={topbarSummary.notificationCount > 0}>
<span class={styles.countBadge}>{topbarSummary.notificationCount}</span>
</Show>
</button>
<Show when={openMenu() === "notifications"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Alerts</p>
<strong>Notifications</strong>
</div>
<A href="/dashboard/progress" class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
Open progress
</A>
</div>
<div class={styles.dropdownList}>
<For each={topbarNotifications}>
{(item) => (
<A href={item.href} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles[`tone-${item.tone}`]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>{item.title}</strong>
<p>{item.description}</p>
</div>
<small>{item.timestamp}</small>
</A>
)}
</For>
</div>
</div>
</Show>
</div>
<div class={styles.menuGroup}>
<button
type="button"
class={styles.iconButton}
aria-label="Messages"
aria-expanded={openMenu() === "messages"}
onClick={() => toggleMenu("messages")}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16v10H8l-4 3V6Z" />
</svg>
<Show when={topbarSummary.messageCount > 0}>
<span class={styles.countBadge}>{topbarSummary.messageCount}</span>
</Show>
</button>
<Show when={openMenu() === "messages"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Inbox</p>
<strong>Messages</strong>
</div>
<A href="/dashboard/messages" class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
Open inbox
</A>
</div>
<div class={styles.dropdownList}>
<For each={topbarMessages}>
{(item) => (
<A href={item.actionHref} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={styles.messageAvatar}>{item.initials}</span>
<div class={styles.itemCopy}>
<strong>{item.sender}</strong>
<p>{item.preview}</p>
</div>
<small>{item.timestamp}</small>
</A>
)}
</For>
</div>
</div>
</Show>
</div>
<div class={styles.menuGroup}>
<button
type="button"
classList={{ [styles.profileButton]: true, [styles.profileButtonOpen]: openMenu() === "profile" }}
aria-label="Profile menu"
aria-expanded={openMenu() === "profile"}
onClick={() => toggleMenu("profile")}
>
<div class={styles.profile}>
<div>
<p class={styles.profileName}>{topbarSummary.profileName}</p>
<p class={styles.profileRole}>{topbarSummary.profileRole}</p>
</div>
<span class={styles.profileBadge}>{topbarSummary.profileBadge}</span>
<span class={styles.profileChevron} aria-hidden="true">
<svg viewBox="0 0 20 20">
<path d="m5 7.5 5 5 5-5" />
</svg>
</span>
</div>
</button>
<Show when={openMenu() === "profile"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Signed in as</p>
<strong>{topbarSummary.profileName}</strong>
</div>
<span class={styles.profileMenuBadge}>{topbarSummary.profileBadge}</span>
</div>
<div class={styles.dropdownList}>
<A href="/dashboard/settings" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-blue"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Profile</strong>
<p>Update your learner details and view your profile settings.</p>
</div>
</A>
<A href="/dashboard/settings" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-yellow"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Settings</strong>
<p>Manage goals, preferences, reminders, and dashboard options.</p>
</div>
</A>
<A href="/auth/login" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-teal"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Log out</strong>
<p>Return to the sign-in screen. This is a UI-only sign out for now.</p>
</div>
</A>
</div>
</div>
</Show>
</div>
</div>
</header>
);
};
export default DashboardTopbar;

View File

@@ -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<string, { correct: number; total: number }>();
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<string, number>();
for (const answer of studentAnswers) {
solveModeBuckets.set(answer._solve_mode, (solveModeBuckets.get(answer._solve_mode) ?? 0) + 1);
}
const solveModeToneMap: Record<string, UsageItem["tone"]> = {
just_answer: "purple",
step_by_step: "blue",
solve_together: "teal",
handwritten: "yellow",
};
const solveModeLabelMap: Record<string, string> = {
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<string, { title: string; recommendation: string }> = {
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: "Todays 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: `Lets 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 todays 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 };

View File

@@ -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<ThemeContextType>();
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<ThemeMode>("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 <ThemeContext.Provider value={{ mode, toggleMode, isReady }}>{props.children}</ThemeContext.Provider>;
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
};

View File

@@ -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(() => (
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{assets}
<link rel="preload" href="/fonts/Poppins/Poppins-Light.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href="/fonts/Poppins/Poppins-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
</head>
<body>
<div id="app">{children}</div>

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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 (
<main class={styles.page}>
<div class={styles.shell}>
{assignmentData() ? (
<>
<AssignmentHeader data={assignmentData()!} />
<AssignmentTabs />
<div class={styles.contentGrid}>
<div class={styles.mainColumn}>
<AssignmentQuestionList data={assignmentData()!} />
</div>
<aside class={styles.sideColumn}>
<AssignmentOverview data={assignmentData()!} />
</aside>
</div>
</>
) : (
<section class={styles.emptyState}>
<p class={styles.emptyEyebrow}>Assignment not found</p>
<h1>We could not find that sample assignment.</h1>
<p>Try one of the mock IDs like 3006 or 3007, or jump back to the dashboard.</p>
<A href="/dashboard" class={styles.backButton}>
Back to dashboard
</A>
</section>
)}
</div>
</main>
);
};
export default AssignmentPage;

View File

@@ -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<DraftState>(() => {
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<DraftState>({});
const [activeQuestionId, setActiveQuestionId] = createSignal<number | null>(null);
const [saveState, setSaveState] = createSignal<"idle" | "saving" | "saved">("idle");
const [lastSavedAt, setLastSavedAt] = createSignal<string | null>(null);
const [isSubmitted, setIsSubmitted] = createSignal(false);
const [submittedAt, setSubmittedAt] = createSignal<string | null>(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<typeof setTimeout> | 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 (
<main class={styles.page}>
<div class={styles.shell}>
<Show
when={assignmentData()}
fallback={
<section class={styles.emptyState}>
<p class={styles.emptyEyebrow}>Assignment not found</p>
<h1>We could not find that assignment workspace.</h1>
<A href="/dashboard" class={styles.backButton}>
Back to dashboard
</A>
</section>
}
>
{(data) => (
<>
<Show when={isSubmitted()}>
<section class={styles.submitBanner}>
<div>
<p class={styles.bannerEyebrow}>Practice submitted</p>
<h2>
You answered {answeredCount()} of {data().questions.length} questions.
</h2>
<p>Your latest draft is saved locally. You can review the assignment or keep refining any answer.</p>
</div>
<A href={`/assignment/${data().id}`} class={styles.bannerLink}>
Open review page
</A>
</section>
</Show>
<section class={styles.headerCard}>
<div class={styles.headerTop}>
<A href={`/assignment/${data().id}`} class={styles.backLink}>
Back to review
</A>
<span class={styles.statusPill}>{data().statusLabel}</span>
</div>
<div class={styles.headerCopy}>
<p class={styles.eyebrow}>{data().classroomName}</p>
<h1>Work through {data().title}</h1>
<p>Answer each question, show your working, and move at a steady pace. Your draft autosaves on this device while you work.</p>
</div>
<div class={styles.headerMeta}>
<p class={styles.saveState}>{saveLabel()}</p>
<button type="button" class={styles.submitButton} onClick={handleSubmit}>
{remainingCount() > 0 ? `Submit ${answeredCount()}/${data().questions.length}` : "Submit assignment"}
</button>
</div>
</section>
<AssignmentTabs />
<div class={styles.contentGrid}>
<aside class={styles.navigator}>
<div class={styles.navigatorHeader}>
<h2>Question list</h2>
<p>
{answeredCount()} of {data().questions.length} answered
</p>
</div>
<div class={styles.progressMeta}>
<span>{remainingCount()} left</span>
<span>{saveLabel()}</span>
</div>
<div class={styles.questionButtons}>
<For each={data().questions}>
{(question) => {
const hasDraft = () => (drafts()[question.id]?.answer ?? "").trim().length > 0;
return (
<button
type="button"
classList={{
[styles.questionButton]: true,
[styles.activeQuestion]: activeQuestionId() === question.id,
[styles.completedQuestion]: hasDraft(),
}}
onClick={() => setActiveQuestionId(question.id)}
>
<span>Q{question.order}</span>
<small>{hasDraft() ? "Draft ready" : question.statusLabel}</small>
</button>
);
}}
</For>
</div>
</aside>
<section class={styles.workspace}>
<Show when={currentQuestion() && activeDraft()}>
{() => (
<>
<div class={styles.workspaceHeader}>
<div>
<p class={styles.questionEyebrow}>Question {currentQuestion()!.order}</p>
<h2>{currentQuestion()!.prompt}</h2>
<p class={styles.questionStatus}>{currentQuestion()!.statusLabel}</p>
</div>
<div class={styles.questionMeta}>
<span>{currentQuestion()!.topic}</span>
<Show when={currentQuestion()!.subTopic}>
<span>{currentQuestion()!.subTopic}</span>
</Show>
<span>{currentQuestion()!.difficulty}</span>
</div>
</div>
<label class={styles.fieldGroup}>
<span>Solve mode</span>
<select value={activeDraft()!.solveMode} onInput={(event) => updateDraft("solveMode", event.currentTarget.value)}>
<For each={solveModeOptions}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
<small class={styles.fieldHint}>Pick the mode that best matches how you want to think through this question.</small>
</label>
<label class={styles.fieldGroup}>
<span>Your answer</span>
<input
type="text"
value={activeDraft()!.answer}
onInput={(event) => updateDraft("answer", event.currentTarget.value)}
placeholder="Type your final answer"
/>
<small class={styles.fieldHint}>Keep this short your final number, fraction, or expression.</small>
</label>
<label class={styles.fieldGroup}>
<span>Working / steps</span>
<textarea
rows={8}
value={activeDraft()!.steps}
onInput={(event) => updateDraft("steps", event.currentTarget.value)}
placeholder="Write your method here. For example: find a common denominator, simplify, then check your final answer."
/>
<small class={styles.fieldHint}>Show enough working that you could explain it back to your teacher later.</small>
</label>
<div class={styles.helperCard}>
<p class={styles.helperEyebrow}>Helpful prompt</p>
<p>Try to explain why each step makes sense, not just what number you wrote next.</p>
<Show when={currentQuestion()!.showAnswerKey}>
<div class={styles.answerReveal}>
<span>Answer key</span>
<strong>{currentQuestion()!.correctAnswer}</strong>
</div>
</Show>
</div>
<div class={styles.actionRow}>
<button type="button" class={styles.secondaryButton} onClick={() => moveQuestion(-1)}>
Previous
</button>
<div class={styles.primaryActions}>
<button type="button" class={styles.secondaryButton} onClick={saveNow}>
Save draft
</button>
<button type="button" class={styles.primaryButton} onClick={handleSaveAndContinue}>
{isLastQuestion() ? "Save this question" : "Save and continue"}
</button>
</div>
</div>
<div class={styles.questionFooter}>
<p>{saveLabel()}</p>
<Show when={submittedAt()}>
<p>
Last submitted {new Intl.DateTimeFormat("en-GB", { hour: "numeric", minute: "2-digit" }).format(new Date(submittedAt()!))}
</p>
</Show>
</div>
</>
)}
</Show>
</section>
</div>
</>
)}
</Show>
</div>
</main>
);
};
export default AssignmentWorkPage;

View File

@@ -0,0 +1,128 @@
.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%, 92rem);
margin: 0 auto;
display: grid;
gap: 1.25rem;
}
.contentGrid {
display: grid;
gap: 1.25rem;
@media (min-width: 1080px) {
grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.75fr);
align-items: start;
}
}
.tabs {
display: flex;
justify-content: flex-start;
}
.tabList {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem;
border-radius: 9999px;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
overflow-x: auto;
max-width: 100%;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabLink {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.72rem 1rem;
border-radius: 9999px;
text-decoration: none;
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
transition:
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
&:hover {
color: var(--text);
background: var(--surface-soft);
}
}
.tabLinkActive {
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
}
.mainColumn,
.sideColumn {
min-width: 0;
}
.sideColumn {
@media (min-width: 1080px) {
position: sticky;
top: 1.25rem;
}
}
.emptyState {
display: grid;
gap: 0.8rem;
padding: 2rem;
border-radius: 1.5rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
h1 {
font-size: clamp(2rem, 1.7rem + 1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
}
p {
color: var(--text-muted);
}
}
.emptyEyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.78rem;
color: var(--text-muted) !important;
}
.backButton {
width: fit-content;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.85rem 1rem;
border-radius: 9999px;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
text-decoration: none;
font-weight: 600;
box-shadow: var(--action-primary-shadow);
}

View File

@@ -0,0 +1,66 @@
import { A, useNavigate } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "./login.module.scss";
const ForgotPassword: Component = () => {
const navigate = useNavigate();
const handleSubmit = (event: Event) => {
event.preventDefault();
navigate("/auth/login");
};
return (
<main class={styles["login-page"]}>
<section class={styles["login-shell"]}>
<div class={styles["login-copy"]}>
<A href="/" class={styles["back-link"]}>
Back to home
</A>
<p class={styles.eyebrow}>Account help</p>
<h1 class={styles.title}>Reset your password</h1>
<p class={styles.description}>We&apos;ll send you a reset link so you can get back into your dashboard quickly.</p>
<div class={styles["benefit-list"]}>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>01</span>
<div>
<h2>Check your inbox</h2>
<p>Use the same email address you signed up with and we&apos;ll send a recovery link there.</p>
</div>
</div>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>02</span>
<div>
<h2>Create a fresh password</h2>
<p>Pick a strong new password, then sign back in and continue your assignments.</p>
</div>
</div>
</div>
</div>
<form class={styles["login-card"]} onSubmit={handleSubmit}>
<div class={styles["card-header"]}>
<h2>Forgot password?</h2>
<p>Enter your email and we&apos;ll send you a reset link.</p>
</div>
<div class={styles["field-group"]}>
<label for="reset-email">Email address</label>
<input id="reset-email" type="email" name="email" autocomplete="email" placeholder="you@example.com" />
</div>
<button type="submit">Send reset link</button>
<p class={styles["support-copy"]}>
Remembered it? <A href="/auth/login">Back to sign in</A>
</p>
</form>
</section>
</main>
);
};
export default ForgotPassword;

View File

@@ -0,0 +1,227 @@
/* Path: Frontend/src/routes/auth/login.module.scss */
.login-page {
min-height: 100dvh;
width: 100%;
overflow: clip;
display: grid;
place-items: center;
padding: 1.5rem;
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));
}
.login-shell {
width: min(100%, 70rem);
display: grid;
grid-template-columns: 1fr;
gap: 2.5rem;
align-items: center;
@include respond(tablet) {
grid-template-columns: minmax(0, 1.05fr) minmax(22rem, 28rem);
align-items: center;
gap: 4rem;
}
}
.login-copy {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 32rem;
}
.back-link,
.description,
.card-header p,
.field-group label,
.field-group input,
.field-header a,
.checkbox-row,
.footer-copy,
.benefit-item p {
@include text-smallest;
}
.back-link,
.field-header a,
.footer-copy a {
color: var(--primary);
}
.back-link {
width: fit-content;
font-weight: 500;
}
.eyebrow {
@include text-smallest;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.title {
font-size: clamp(2.8rem, 2.2rem + 1.9vw, 4.5rem);
line-height: 1.05;
max-width: 10ch;
}
.description,
.footer-copy,
.card-header p,
.benefit-item p {
color: var(--text-muted);
}
.benefit-list {
display: grid;
gap: 1rem;
margin-top: 0.75rem;
}
.benefit-item {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.875rem;
align-items: start;
padding: 1rem 1.1rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
border-radius: 1rem;
}
.benefit-number {
display: inline-grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 9999px;
color: var(--primary);
background: var(--surface-accent-soft);
font-size: 0.9rem;
font-weight: 600;
}
.benefit-item h2 {
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.login-card {
display: flex;
flex-direction: column;
gap: 1.35rem;
padding: 2rem;
background: var(--surface-raised);
border: 1px solid var(--border-strong);
border-radius: 1.75rem;
box-shadow: var(--shadow-elevated);
backdrop-filter: blur(14px);
}
.card-header {
display: flex;
flex-direction: column;
gap: 0.35rem;
text-align: center;
}
.card-header h2 {
font-size: 1.65rem;
font-weight: 500;
}
.field-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-grid {
display: grid;
gap: 0.85rem;
@media (min-width: 540px) {
grid-template-columns: 1fr 1fr;
}
}
.field-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.field-group input {
width: 100%;
padding: 0.95rem 1rem;
color: var(--text);
background: var(--surface-field);
border: 1px solid var(--border-field);
border-radius: 1rem;
outline: none;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
&:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px var(--focus-ring-primary);
background: var(--surface-field-focus);
}
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-muted);
line-height: 1.4;
}
.checkbox-row input {
width: 1rem;
height: 1rem;
}
.login-card button {
width: 100%;
padding: 0.95rem 1.25rem;
color: var(--action-primary-text);
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
border: none;
border-radius: 9999px;
cursor: pointer;
font-weight: 500;
box-shadow: var(--action-primary-shadow);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--action-primary-shadow-hover);
filter: brightness(1.03);
}
}
.footer-copy {
text-align: center;
}
.support-copy {
text-align: center;
color: var(--text-muted);
a {
color: var(--primary);
}
}

View File

@@ -0,0 +1,85 @@
// Path: Frontend/src/routes/auth/login.tsx
import { A, useNavigate } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "./login.module.scss";
const Login: Component = () => {
const navigate = useNavigate();
const handleSubmit = (event: Event) => {
event.preventDefault();
navigate("/dashboard");
};
return (
<main class={styles["login-page"]}>
<section class={styles["login-shell"]}>
<div class={styles["login-copy"]}>
<A href="/" class={styles["back-link"]}>
Back to home
</A>
<p class={styles.eyebrow}>Welcome back</p>
<h1 class={styles.title}>Sign in to Rooster AI</h1>
<p class={styles.description}>Access your learning dashboard, questions, and progress in one place.</p>
<div class={styles["benefit-list"]}>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>01</span>
<div>
<h2>Track your progress</h2>
<p>Pick up from the last lesson, review performance, and keep your momentum.</p>
</div>
</div>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>02</span>
<div>
<h2>Ask better questions</h2>
<p>Get help on Maths and English topics with a cleaner learning workflow.</p>
</div>
</div>
</div>
</div>
<form class={styles["login-card"]} onSubmit={handleSubmit}>
<div class={styles["card-header"]}>
<h2>Sign in</h2>
<p>Use your email and password to continue.</p>
</div>
<div class={styles["field-group"]}>
<label for="email">Email address</label>
<input id="email" type="email" name="email" autocomplete="email" placeholder="you@example.com" />
</div>
<div class={styles["field-group"]}>
<div class={styles["field-header"]}>
<label for="password">Password</label>
<A href="/auth/forgot-password">Forgot password?</A>
</div>
<input id="password" type="password" name="password" autocomplete="current-password" placeholder="Enter your password" />
</div>
<label class={styles["checkbox-row"]} for="remember-me">
<input id="remember-me" type="checkbox" name="rememberMe" />
<span>Keep me signed in</span>
</label>
<button type="submit">
Sign in
</button>
<p class={styles["footer-copy"]}>
Don&apos;t have an account yet? <A href="/auth/signup">Create one</A>
</p>
</form>
</section>
</main>
);
};
export default Login;

View File

@@ -0,0 +1,99 @@
import { A, useNavigate } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "./login.module.scss";
const Signup: Component = () => {
const navigate = useNavigate();
const handleSubmit = (event: Event) => {
event.preventDefault();
navigate("/dashboard");
};
return (
<main class={styles["login-page"]}>
<section class={styles["login-shell"]}>
<div class={styles["login-copy"]}>
<A href="/" class={styles["back-link"]}>
Back to home
</A>
<p class={styles.eyebrow}>Start learning</p>
<h1 class={styles.title}>Create your account</h1>
<p class={styles.description}>Set up your learner profile and jump straight into assignments, progress, and practice.</p>
<div class={styles["benefit-list"]}>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>01</span>
<div>
<h2>Your own dashboard</h2>
<p>Track assignments, confidence areas, and recent results in one place.</p>
</div>
</div>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>02</span>
<div>
<h2>Save your working</h2>
<p>Keep your answers, step-by-step thinking, and practice history connected to your account.</p>
</div>
</div>
</div>
</div>
<form class={styles["login-card"]} onSubmit={handleSubmit}>
<div class={styles["card-header"]}>
<h2>Create account</h2>
<p>Tell us a little about you to get started.</p>
</div>
<div class={styles["field-grid"]}>
<div class={styles["field-group"]}>
<label for="first-name">First name</label>
<input id="first-name" type="text" name="firstName" autocomplete="given-name" placeholder="Aisha" />
</div>
<div class={styles["field-group"]}>
<label for="last-name">Last name</label>
<input id="last-name" type="text" name="lastName" autocomplete="family-name" placeholder="Khan" />
</div>
</div>
<div class={styles["field-group"]}>
<label for="signup-email">Email address</label>
<input id="signup-email" type="email" name="email" autocomplete="email" placeholder="you@example.com" />
</div>
<div class={styles["field-group"]}>
<label for="signup-password">Password</label>
<input id="signup-password" type="password" name="password" autocomplete="new-password" placeholder="Create a password" />
</div>
<div class={styles["field-group"]}>
<label for="signup-confirm-password">Confirm password</label>
<input
id="signup-confirm-password"
type="password"
name="confirmPassword"
autocomplete="new-password"
placeholder="Repeat your password"
/>
</div>
<label class={styles["checkbox-row"]} for="agree-terms">
<input id="agree-terms" type="checkbox" name="agreeTerms" />
<span>I agree to the learning platform terms and understand this is a sample signup flow.</span>
</label>
<button type="submit">Create account</button>
<p class={styles["footer-copy"]}>
Already have an account? <A href="/auth/login">Sign in</A>
</p>
</form>
</section>
</main>
);
};
export default Signup;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardAssignmentsFocus from "../../components/dashboard/dashboard-assignments-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardAssignmentsPage: Component = () => {
return (
<DashboardShell>
<DashboardAssignmentsFocus />
</DashboardShell>
);
};
export default DashboardAssignmentsPage;

View File

@@ -0,0 +1,200 @@
.dashboardPage {
min-height: 100dvh;
padding: 0.85rem;
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));
@media (min-width: 640px) {
padding: 1rem;
}
@media (min-width: 960px) {
padding: 1.25rem;
}
}
.dashboardLayout {
width: min(100%, 100rem);
margin: 0 auto;
display: grid;
gap: 1rem;
--dashboard-sidebar-width: 0rem;
will-change: grid-template-columns;
transition:
grid-template-columns 0.46s cubic-bezier(0.22, 1, 0.36, 1),
gap 0.26s var(--transition-ease);
@media (min-width: 960px) {
gap: 1.25rem;
}
@media (min-width: 1024px) {
--dashboard-sidebar-width: 15.5rem;
grid-template-columns: minmax(0, var(--dashboard-sidebar-width)) minmax(0, 1fr);
grid-template-areas: "sidebar main";
align-items: start;
}
}
.dashboardLayoutSidebarCollapsed {
@media (min-width: 1024px) {
--dashboard-sidebar-width: 0rem;
}
}
.sidebarRail {
position: fixed;
inset: 0;
z-index: 40;
pointer-events: none;
@media (min-width: 1024px) {
position: static;
inset: auto;
z-index: auto;
pointer-events: auto;
overflow: hidden;
min-width: 0;
}
}
.sidebarBackdrop {
position: absolute;
inset: 0;
border: none;
padding: 0;
background: hsl(0 0% 0% / 0.48);
opacity: 0;
pointer-events: none;
transition:
opacity 0.34s var(--transition-ease),
backdrop-filter 0.34s var(--transition-ease);
backdrop-filter: blur(0px);
@media (min-width: 1024px) {
display: none;
}
}
.sidebarBackdropVisible {
opacity: 1;
pointer-events: auto;
backdrop-filter: blur(2px);
}
.sidebarPanel {
position: absolute;
top: 0.85rem;
left: 0.85rem;
bottom: 0.85rem;
width: min(18rem, calc(100vw - 3.2rem));
max-width: 100%;
overflow-y: auto;
transform: translateX(calc(-100% - 1rem));
opacity: 0;
transition:
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.3s var(--transition-ease);
pointer-events: auto;
@media (min-width: 640px) {
top: 1rem;
left: 1rem;
bottom: 1rem;
}
@media (min-width: 1024px) {
position: static;
width: auto;
transform: none;
opacity: 1;
transition:
opacity 0.3s var(--transition-ease),
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
visibility 0.3s var(--transition-ease),
max-width 0.46s cubic-bezier(0.22, 1, 0.36, 1);
max-width: 15.5rem;
}
}
.sidebarPanelOpen {
transform: translateX(0);
opacity: 1;
}
.sidebarPanelDesktopCollapsed {
@media (min-width: 1024px) {
max-width: 0;
opacity: 0;
visibility: hidden;
transform: translateX(-1.25rem) scale(0.985);
pointer-events: none;
}
}
.sidebarPanelInner {
transition:
opacity 0.32s var(--transition-ease),
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.42s var(--transition-ease);
transform-origin: top left;
}
.sidebarPanelDesktopCollapsed .sidebarPanelInner {
@media (min-width: 1024px) {
opacity: 0;
transform: translateX(-0.9rem) scale(0.98);
filter: blur(4px);
}
}
.dashboardMain {
display: grid;
gap: 1rem;
min-width: 0;
will-change: transform, opacity;
transition:
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.28s var(--transition-ease);
@media (min-width: 960px) {
gap: 1.25rem;
}
}
.dashboardLayoutSidebarCollapsed .dashboardMain {
@media (min-width: 1024px) {
transform: translateX(-0.18rem);
}
}
.contentGrid {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
@media (min-width: 1120px) {
grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.92fr);
grid-template-areas:
"activity courses"
"activity instructors";
align-items: start;
> :nth-child(1) {
grid-area: courses;
}
> :nth-child(2) {
grid-area: activity;
}
> :nth-child(3) {
grid-area: instructors;
}
}
}

View File

@@ -0,0 +1,26 @@
import type { Component } from "solid-js";
import DashboardActivity from "../../components/dashboard/dashboard-activity";
import DashboardCourses from "../../components/dashboard/dashboard-courses";
import DashboardHero from "../../components/dashboard/dashboard-hero";
import DashboardInsights from "../../components/dashboard/dashboard-insights";
import DashboardInstructors from "../../components/dashboard/dashboard-instructors";
import DashboardShell from "../../components/dashboard/dashboard-shell";
import styles from "./dashboard.module.scss";
const DashboardPage: Component = () => {
return (
<DashboardShell>
<DashboardHero />
<div class={styles.contentGrid}>
<DashboardCourses />
<DashboardActivity />
<DashboardInstructors />
</div>
<DashboardInsights />
</DashboardShell>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardMessagesFocus from "../../components/dashboard/dashboard-messages-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardMessagesPage: Component = () => {
return (
<DashboardShell>
<DashboardMessagesFocus />
</DashboardShell>
);
};
export default DashboardMessagesPage;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardPracticeFocus from "../../components/dashboard/dashboard-practice-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardPracticePage: Component = () => {
return (
<DashboardShell>
<DashboardPracticeFocus />
</DashboardShell>
);
};
export default DashboardPracticePage;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardProgressFocus from "../../components/dashboard/dashboard-progress-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardProgressPage: Component = () => {
return (
<DashboardShell>
<DashboardProgressFocus />
</DashboardShell>
);
};
export default DashboardProgressPage;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardSettingsFocus from "../../components/dashboard/dashboard-settings-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardSettingsRoute: Component = () => {
return (
<DashboardShell>
<DashboardSettingsFocus />
</DashboardShell>
);
};
export default DashboardSettingsRoute;

View File

@@ -0,0 +1,26 @@
// Path: Frontend/src/routes/index.tsx
import { useNavigate } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "./landing.module.scss";
const HomePage: Component = () => {
const navigate = useNavigate();
const handleNavigation = () => {
navigate("/auth/login");
};
return (
<main class={styles.landingPage}>
<h1>Rooster AI Demo</h1>
<sub>The leading AI online learning platform for Maths and English</sub>
<button type="button" onClick={handleNavigation}>
Get Started
</button>
</main>
);
};
export default HomePage;

View File

@@ -0,0 +1,50 @@
/* Path: Frontend/src/routes/landing.module.scss */
.landingPage {
min-height: 100dvh;
width: 100%;
overflow: hidden;
padding: 1.5rem;
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));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
h1 {
color: var(--primary);
}
sub {
font-weight: 300;
}
button {
margin-top: 2rem;
padding: 0.875rem 1.75rem;
font-size: 1rem;
font-weight: 500;
color: var(--action-primary-text);
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
border: none;
border-radius: 9999px;
cursor: pointer;
box-shadow: var(--action-primary-shadow);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--action-primary-shadow-hover);
filter: brightness(1.03);
}
}
}

View File

@@ -0,0 +1,31 @@
/* Path: Frontend/src/styles/_fonts.scss */
@font-face {
font-family: "CaveatBrush";
src: url("/fonts/CaveatBrush/CaveatBrush-Regular.woff2") format("woff2");
font-display: swap;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins/Poppins-Light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins/Poppins-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins/Poppins-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,52 @@
/* Path: Frontend/src/styles/_reset.scss */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html,
body {
height: 100%;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
#root,
#__next {
isolation: isolate;
}

View File

@@ -0,0 +1,125 @@
/* Path: Frontend/src/styles/main.scss */
@use "./reset" as *;
@use "./fonts" as *;
html {
background-color: var(--bg);
// transition:
// background-color var(--transition-speed) var(--transition-ease),
// color var(--transition-speed) var(--transition-ease);
color: var(--text);
font-family:
"Poppins",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
overflow-x: hidden;
scroll-behavior: smooth;
}
body {
background-color: var(--bg);
}
.app-route-shell {
position: relative;
width: 100%;
isolation: isolate;
overflow-x: clip;
}
.app-route-shell-viewport-locked {
min-height: 100dvh;
overflow: clip;
}
.page-transition-stage {
width: 100%;
will-change: opacity, transform, filter;
}
.page-fade-slide-enter-active,
.page-fade-slide-exit-active {
transition:
opacity 0.2s var(--transition-ease),
transform 0.24s var(--transition-ease),
filter 0.24s var(--transition-ease);
}
.page-fade-slide-exit-active {
position: absolute;
inset: 0;
pointer-events: none;
}
.page-fade-slide-enter,
.page-fade-slide-exit-to {
opacity: 0;
transform: translateY(0.45rem) scale(0.996);
filter: blur(3px);
}
h1 {
@include text-largest;
}
h2 {
@include text-large;
}
h3 {
@include text-medium;
}
h4 {
@include text-small;
}
h5 {
@include text-smaller;
}
p,
span,
button,
input,
textarea,
sub,
a {
@include text-smallest;
}
a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
h1,
h2,
h3,
h4,
h5,
p,
span,
a,
button,
input,
textarea {
transition: color var(--transition-speed) var(--transition-ease);
}

View File

@@ -0,0 +1,290 @@
/* Path: Frontend/src/styles/vars.scss */
:root {
--transition-speed: 0.5s;
--transition-ease: ease-in-out;
--gray-50: hsl(40 25% 85%);
--gray-100: hsl(40 23% 80%);
--gray-200: hsl(40 20% 77%);
--gray-300: hsl(40 20% 70%);
--gray-400: hsl(40 10% 50%);
--gray-500: hsl(40 5% 40%);
--gray-600: hsl(40 5% 30%);
--gray-700: hsl(40 5% 25%);
--gray-800: hsl(40 5% 20%);
--gray-900: hsl(40 5% 15%);
--purple-100: hsl(263.08, 61.9%, 95.88%);
--purple-200: hsl(264.71, 48.57%, 93.14%);
--purple-300: hsl(266.44, 50.43%, 77.06%);
--purple-400: hsl(259.47, 67.11%, 55.88%);
--purple-500: hsl(261.9, 52.94%, 46.67%);
--purple-600: hsl(267, 27%, 35%);
--purple-700: hsl(266.4, 26.88%, 25.24%);
--purple-800: hsl(266.4, 26.88%, 18.24%);
--green-100: hsl(142.63, 100%, 94.71%);
--green-200: hsl(142.63, 100%, 64.71%);
--green-300: hsl(142.63, 100%, 44.71%);
--green-400: hsl(142.63, 100%, 34.71%);
--green-500: hsl(142.63, 100%, 24.71%);
--blue-100: hsl(204 100% 94%);
--blue-200: hsl(204 92% 84%);
--blue-300: hsl(204 88% 64%);
--blue-400: hsl(204 82% 54%);
--blue-500: hsl(204 76% 48%);
--teal-100: hsl(172 46% 90%);
--teal-200: hsl(172 46% 74%);
--teal-300: hsl(172 43% 57%);
--teal-400: hsl(172 52% 46%);
--teal-500: hsl(172 58% 38%);
--orange-100: hsl(31 100% 92%);
--orange-200: hsl(31 100% 79%);
--orange-300: hsl(31 94% 66%);
--orange-400: hsl(31 89% 57%);
--orange-500: hsl(31 78% 47%);
--red-100: hsl(359 46.6% 90.8%);
--red-200: hsl(359 46.6% 70.8%);
--red-300: hsl(359 46.6% 50.8%);
--red-400: hsl(359 46.6% 40.8%);
--red-500: hsl(359 46.6% 30.8%);
--yellow-100: hsl(56.94, 100%, 90%);
--yellow-200: hsl(56.94, 100%, 70%);
--yellow-300: hsl(56.94, 100%, 50%);
--yellow-400: hsl(56.94, 100%, 40%);
--yellow-500: hsl(56.94, 100%, 30%);
}
:root {
// Background and Text Colors
--bg: var(--gray-50);
--text: var(--gray-800);
--text-muted: var(--gray-700);
// Page Background
--page-glow-primary: hsl(263 62% 96% / 0.9);
--page-glow-secondary: hsl(142 100% 94% / 0.65);
--page-gradient-start: hsl(40 25% 91%);
--page-gradient-end: hsl(40 18% 87%);
// Surface Tokens
--surface-soft: hsl(40 25% 93% / 0.65);
--surface-raised: hsl(40 25% 92% / 0.78);
--surface-field: hsl(40 30% 95% / 0.85);
--surface-field-focus: hsl(40 30% 97% / 0.95);
--surface-accent-soft: hsl(263 62% 92% / 0.9);
--surface-accent-emphasis: hsl(263 62% 88% / 0.95);
--surface-panel: hsl(40 24% 95% / 0.9);
--surface-panel-strong: hsl(40 18% 98% / 0.95);
--surface-info: hsl(204 100% 96% / 0.95);
--surface-info-emphasis: hsl(204 90% 88% / 0.95);
--surface-success: hsl(142 100% 96% / 0.95);
--surface-success-emphasis: hsl(172 44% 83% / 0.95);
--surface-warning: hsl(56.94 100% 94% / 0.95);
--surface-warning-emphasis: hsl(56.94 100% 82% / 0.95);
--surface-danger: hsl(359 46.6% 94% / 0.95);
--surface-danger-emphasis: hsl(359 46.6% 86% / 0.95);
--surface-hero-start: hsl(172 38% 62%);
--surface-hero-end: hsl(172 46% 50%);
// Border Tokens
--border-soft: hsl(40 18% 80% / 0.7);
--border-strong: hsl(40 18% 80% / 0.85);
--border-field: hsl(40 14% 76% / 0.95);
--border-divider: hsl(40 14% 78% / 0.5);
// Elevation + Focus
--shadow-elevated: 0 20px 45px hsl(40 15% 25% / 0.12);
--shadow-soft: 0 12px 28px hsl(40 15% 25% / 0.08);
--focus-ring-primary: hsl(263 62% 85% / 0.35);
// On-accent / Overlay Tokens
--text-on-accent: hsl(0 0% 100%);
--text-on-accent-muted: hsl(0 0% 100% / 0.82);
--text-accent-strong: hsl(266 40% 30%);
--text-info-strong: hsl(204 70% 34%);
--text-success-strong: hsl(172 55% 28%);
--text-warning-strong: hsl(34 78% 28%);
--text-danger-strong: hsl(359 44% 34%);
--surface-overlay-soft: hsl(0 0% 100% / 0.14);
--surface-overlay-strong: hsl(0 0% 100% / 0.2);
--border-overlay: hsl(0 0% 100% / 0.28);
// Action Tokens
--action-primary-text: var(--gray-50);
--action-primary-start: var(--purple-500);
--action-primary-end: var(--primary);
--action-primary-shadow: 0 12px 24px hsl(263 53% 45% / 0.24);
--action-primary-shadow-hover: 0 16px 30px hsl(263 53% 45% / 0.3);
// Accent
--primary: var(--purple-400);
--primary-hover: var(--purple-500);
--secondary: var(--green-300);
--secondary-hover: var(--green-200);
--warning: var(--red-300);
--warning-hover: var(--red-200);
--info: var(--blue-400);
--info-hover: var(--blue-500);
--success: var(--teal-400);
--success-hover: var(--teal-500);
// Typical Card Button Colors
--bg-1: var(--gray-100);
--bg-1-hover: var(--gray-200);
--bg-2: var(--gray-200);
--bg-2-hover: var(--gray-300);
--bg-3: var(--gray-300);
--bg-3-hover: var(--gray-400);
--box-shadow: 0 8px 16px hsl(0 0% 0% / 0.1);
}
[data-color-scheme="dark"] {
// Background and Text Colors
--bg: var(--gray-900);
--text: var(--gray-50);
--text-muted: var(--gray-400);
// Page Background
--page-glow-primary: hsl(266 38% 16% / 0.34);
--page-glow-secondary: hsl(172 42% 14% / 0.26);
--page-gradient-start: hsl(266 16% 11%);
--page-gradient-end: hsl(40 10% 8%);
// Surface Tokens
--surface-soft: hsl(266 12% 15% / 0.62);
--surface-raised: hsl(266 12% 13% / 0.9);
--surface-field: hsl(266 10% 18% / 0.94);
--surface-field-focus: hsl(266 10% 21% / 0.98);
--surface-accent-soft: hsl(266 22% 20% / 0.9);
--surface-accent-emphasis: hsl(266 24% 26% / 0.9);
--surface-panel: hsl(266 12% 15% / 0.92);
--surface-panel-strong: hsl(266 12% 18% / 0.96);
--surface-info: hsl(204 28% 18% / 0.92);
--surface-info-emphasis: hsl(204 34% 26% / 0.9);
--surface-success: hsl(142 24% 17% / 0.92);
--surface-success-emphasis: hsl(172 26% 24% / 0.9);
--surface-warning: hsl(41 28% 18% / 0.92);
--surface-warning-emphasis: hsl(38 32% 24% / 0.92);
--surface-danger: hsl(359 22% 20% / 0.94);
--surface-danger-emphasis: hsl(359 24% 26% / 0.94);
--surface-hero-start: hsl(172 32% 26%);
--surface-hero-end: hsl(172 38% 20%);
// Border Tokens
--border-soft: hsl(266 10% 24% / 0.72);
--border-strong: hsl(266 10% 26% / 0.9);
--border-field: hsl(266 10% 30% / 0.95);
--border-divider: hsl(266 10% 28% / 0.42);
// Elevation + Focus
--shadow-elevated: 0 20px 45px hsl(0 0% 0% / 0.35);
--shadow-soft: 0 12px 28px hsl(0 0% 0% / 0.22);
--focus-ring-primary: hsl(263 62% 60% / 0.25);
// On-accent / Overlay Tokens
--text-on-accent: hsl(0 0% 100%);
--text-on-accent-muted: hsl(0 0% 100% / 0.78);
--text-accent-strong: hsl(266 68% 78%);
--text-info-strong: hsl(204 78% 76%);
--text-success-strong: hsl(172 60% 72%);
--text-warning-strong: hsl(41 88% 76%);
--text-danger-strong: hsl(359 72% 80%);
--surface-overlay-soft: hsl(0 0% 100% / 0.08);
--surface-overlay-strong: hsl(0 0% 100% / 0.14);
--border-overlay: hsl(0 0% 100% / 0.16);
// Action Tokens
--action-primary-text: var(--gray-50);
--action-primary-start: var(--purple-500);
--action-primary-end: var(--primary);
--action-primary-shadow: 0 12px 24px hsl(263 53% 20% / 0.4);
--action-primary-shadow-hover: 0 16px 30px hsl(263 53% 18% / 0.48);
// Accent
--primary: var(--purple-300);
--primary-hover: var(--purple-200);
--secondary: var(--green-300);
--secondary-hover: var(--green-200);
--info: var(--blue-300);
--info-hover: var(--blue-200);
--success: var(--teal-300);
--success-hover: var(--teal-200);
// Typical Card Button Colors
--bg-1: var(--gray-800);
--bg-1-hover: var(--gray-700);
--bg-2: var(--gray-700);
--bg-2-hover: var(--gray-600);
--bg-3: var(--gray-600);
--bg-3-hover: var(--gray-500);
}
// Breakpoints
$mobile: 768px;
$tablet: 1024px;
$desktop: 1440px;
// The Mixin: Now checks for MIN-width
@mixin respond($breakpoint) {
@if $breakpoint == mobile {
@media (min-width: $mobile) {
@content;
}
} @else if $breakpoint == tablet {
@media (min-width: $tablet) {
@content;
}
} @else if $breakpoint == desktop {
@media (min-width: $desktop) {
@content;
}
}
}
@mixin text-smallest {
font-size: 1rem;
font-size: clamp(1rem, 0.9295774647887324rem + 0.300469483568075vw, 1.2rem);
font-weight: 300;
}
@mixin text-smaller {
font-size: 1.1rem;
font-size: clamp(1.1rem, 0.959154929577465rem + 0.60093896713615vw, 1.5rem);
font-weight: 300;
}
@mixin text-small {
font-size: 1.25rem;
font-size: clamp(1.25rem, 0.9859154929577465rem + 1.1267605633802815vw, 2rem);
font-weight: 300;
}
@mixin text-medium {
font-size: 1.5rem;
font-size: clamp(1.5rem, 1.147887323943662rem + 1.5023474178403755vw, 2.5rem);
font-weight: 400;
}
@mixin text-large {
font-size: 1.75rem;
font-size: clamp(1.75rem, 1.3098591549295775rem + 1.8779342723004695vw, 3rem);
font-weight: 400;
}
@mixin text-largest {
font-size: 2rem;
font-size: clamp(2rem, 1.295774647887324rem + 3.004694835680751vw, 4rem);
font-weight: 400;
}

View File

@@ -14,4 +14,11 @@ export default defineConfig({
server: {
allowedHosts: ["localhost", ...extraAllowedHosts],
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "/src/styles/vars" as *; \n`,
},
},
},
});