Unified · Split
| @@ -34,3 +34,6 @@ yarn-error.log* · 33 unmodified lines | |||
| 34 | # typescript | 34 | # typescript |
| 35 | *.tsbuildinfo | 35 | *.tsbuildinfo |
| 36 | next-env.d.ts | 36 | next-env.d.ts |
| 37 | |||
| 38 | # clerk configuration (can include secrets) | ||
| 39 | /.clerk/ | ||
| @@ -0,0 +1,6 @@ | |||
| 1 | # SumTex Agent Notes | ||
| 2 | |||
| 3 | - Before visual work on SumTex gameplay surfaces, inspect the xAI reference saved at `/home/krisyotam/dl/xai-style.html` and its assets in `/home/krisyotam/dl/xai-style_files/`. | ||
| 4 | - The current play surfaces should follow that reference: black canvas, large sparse type, thin dividers, muted secondary text, outline pill controls, and no blue UI. | ||
| 5 | - Profile/activity design should follow `/home/krisyotam/src/notes.krisyotam.com/src/app/activity`. | ||
| 6 | - Links must not render blue or underlined. | ||
| @@ -9,7 +9,7 @@ · 8 unmodified lines | |||
| 9 | "version": "3.0.0", | 9 | "version": "3.0.0", |
| 10 | "license": "MIT", | 10 | "license": "MIT", |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@workos-inc/authkit-nextjs": "latest", | 12 | "@clerk/nextjs": "^7.5.17", |
| 13 | "better-react-mathjax": "^2.1.0", | 13 | "better-react-mathjax": "^2.1.0", |
| 14 | "drizzle-orm": "^0.44.0", | 14 | "drizzle-orm": "^0.44.0", |
| 15 | "geist": "^1.4.1", | 15 | "geist": "^1.4.1", |
| @@ -45,6 +45,85 @@ · 29 unmodified lines | |||
| 45 | "url": "https://github.com/sponsors/sindresorhus" | 45 | "url": "https://github.com/sponsors/sindresorhus" |
| 46 | } | 46 | } |
| 47 | }, | 47 | }, |
| 48 | "node_modules/@clerk/backend": { | ||
| 49 | "version": "3.11.4", | ||
| 50 | "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.11.4.tgz", | ||
| 51 | "integrity": "sha512-w4SaKIMuXYJY9bU70ZHxNZtam0GYWlT0IIN7QHsZ1p9LC+v6P/JyHqVK9PNiXjcjVTZymMxkm5d9N6//9MBDzg==", | ||
| 52 | "license": "MIT", | ||
| 53 | "dependencies": { | ||
| 54 | "@clerk/shared": "^4.25.2", | ||
| 55 | "standardwebhooks": "^1.0.0", | ||
| 56 | "tslib": "2.8.1" | ||
| 57 | }, | ||
| 58 | "engines": { | ||
| 59 | "node": ">=20.9.0" | ||
| 60 | } | ||
| 61 | }, | ||
| 62 | "node_modules/@clerk/nextjs": { | ||
| 63 | "version": "7.5.17", | ||
| 64 | "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.5.17.tgz", | ||
| 65 | "integrity": "sha512-6RtpFRQ9VhQzMe9vjhVgcVHM6oeSEn+ZP8TYTbnluJG18F+tcIM8FHn4QMGn0tZvPfDxmODKeyZR4gz8FJ620g==", | ||
| 66 | "license": "MIT", | ||
| 67 | "dependencies": { | ||
| 68 | "@clerk/backend": "^3.11.4", | ||
| 69 | "@clerk/react": "^6.12.2", | ||
| 70 | "@clerk/shared": "^4.25.2", | ||
| 71 | "server-only": "0.0.1", | ||
| 72 | "tslib": "2.8.1" | ||
| 73 | }, | ||
| 74 | "engines": { | ||
| 75 | "node": ">=20.9.0" | ||
| 76 | }, | ||
| 77 | "peerDependencies": { | ||
| 78 | "next": "^15.2.8 || ^15.3.8 || ^15.4.10 || ^15.5.9 || ^15.6.0-0 || ^16.0.10 || ^16.1.0-0", | ||
| 79 | "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", | ||
| 80 | "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" | ||
| 81 | } | ||
| 82 | }, | ||
| 83 | "node_modules/@clerk/react": { | ||
| 84 | "version": "6.12.2", | ||
| 85 | "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.12.2.tgz", | ||
| 86 | "integrity": "sha512-Y+OGZlYfRW+3b+fjdbvSYyYB/IraGitrlLJKYaHxeZYNIwvoUNH0zlreSxUSmxYZ0E2yETSEvkCXszvuZUMa3A==", | ||
| 87 | "license": "MIT", | ||
| 88 | "dependencies": { | ||
| 89 | "@clerk/shared": "^4.25.2", | ||
| 90 | "tslib": "2.8.1" | ||
| 91 | }, | ||
| 92 | "engines": { | ||
| 93 | "node": ">=20.9.0" | ||
| 94 | }, | ||
| 95 | "peerDependencies": { | ||
| 96 | "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", | ||
| 97 | "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" | ||
| 98 | } | ||
| 99 | }, | ||
| 100 | "node_modules/@clerk/shared": { | ||
| 101 | "version": "4.25.2", | ||
| 102 | "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.25.2.tgz", | ||
| 103 | "integrity": "sha512-v7zgDh0eVLl3KzRHbKYTsPQ+4Xcx8A2oiU5iZ9WhME024WudwkjjPJRK12RycY0VA1Sj8i4Pi5zEpecqB/gp1Q==", | ||
| 104 | "license": "MIT", | ||
| 105 | "dependencies": { | ||
| 106 | "@tanstack/query-core": "^5.100.6", | ||
| 107 | "dequal": "2.0.3", | ||
| 108 | "glob-to-regexp": "0.4.1", | ||
| 109 | "js-cookie": "3.0.7" | ||
| 110 | }, | ||
| 111 | "engines": { | ||
| 112 | "node": ">=20.9.0" | ||
| 113 | }, | ||
| 114 | "peerDependencies": { | ||
| 115 | "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", | ||
| 116 | "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" | ||
| 117 | }, | ||
| 118 | "peerDependenciesMeta": { | ||
| 119 | "react": { | ||
| 120 | "optional": true | ||
| 121 | }, | ||
| 122 | "react-dom": { | ||
| 123 | "optional": true | ||
| 124 | } | ||
| 125 | } | ||
| 126 | }, | ||
| 48 | "node_modules/@drizzle-team/brocli": { | 127 | "node_modules/@drizzle-team/brocli": { |
| 49 | "version": "0.10.2", | 128 | "version": "0.10.2", |
| 50 | "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", | 129 | "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", |
| @@ -1650,17 +1729,11 @@ · 1599 unmodified lines | |||
| 1650 | "node": ">= 10" | 1729 | "node": ">= 10" |
| 1651 | } | 1730 | } |
| 1652 | }, | 1731 | }, |
| 1653 | "node_modules/@sindresorhus/fnv1a": { | 1732 | "node_modules/@stablelib/base64": { |
| 1654 | "version": "3.1.0", | 1733 | "version": "1.0.1", |
| 1655 | "resolved": "https://registry.npmjs.org/@sindresorhus/fnv1a/-/fnv1a-3.1.0.tgz", | 1734 | "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", |
| 1656 | "integrity": "sha512-KV321z5m/0nuAg83W1dPLy85HpHDk7Sdi4fJbwvacWsEhAh+rZUW4ZfGcXmUIvjZg4ss2bcwNlRhJ7GBEUG08w==", | 1735 | "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", |
| 1657 | "license": "MIT", | 1736 | "license": "MIT" |
| 1658 | "engines": { | ||
| 1659 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" | ||
| 1660 | }, | ||
| 1661 | "funding": { | ||
| 1662 | "url": "https://github.com/sponsors/sindresorhus" | ||
| 1663 | } | ||
| 1664 | }, | 1737 | }, |
| 1665 | "node_modules/@swc/helpers": { | 1738 | "node_modules/@swc/helpers": { |
| 1666 | "version": "0.5.15", | 1739 | "version": "0.5.15", |
| @@ -1954,6 +2027,16 @@ · 287 unmodified lines | |||
| 1954 | "tailwindcss": "4.3.0" | 2027 | "tailwindcss": "4.3.0" |
| 1955 | } | 2028 | } |
| 1956 | }, | 2029 | }, |
| 2030 | "node_modules/@tanstack/query-core": { | ||
| 2031 | "version": "5.101.2", | ||
| 2032 | "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", | ||
| 2033 | "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", | ||
| 2034 | "license": "MIT", | ||
| 2035 | "funding": { | ||
| 2036 | "type": "github", | ||
| 2037 | "url": "https://github.com/sponsors/tannerlinsley" | ||
| 2038 | } | ||
| 2039 | }, | ||
| 1957 | "node_modules/@types/node": { | 2040 | "node_modules/@types/node": { |
| 1958 | "version": "25.9.1", | 2041 | "version": "25.9.1", |
| 1959 | "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", | 2042 | "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", |
| @@ -1984,40 +2067,6 @@ · 24 unmodified lines | |||
| 1984 | "@types/react": "^19.2.0" | 2067 | "@types/react": "^19.2.0" |
| 1985 | } | 2068 | } |
| 1986 | }, | 2069 | }, |
| 1987 | "node_modules/@workos-inc/authkit-nextjs": { | ||
| 1988 | "version": "4.1.1", | ||
| 1989 | "resolved": "https://registry.npmjs.org/@workos-inc/authkit-nextjs/-/authkit-nextjs-4.1.1.tgz", | ||
| 1990 | "integrity": "sha512-j7kf3xw2inF8UdhrOOQMoIkCPJaf7CppmJtagDqpeLO9uLoVpnickJCOkrCpY16hIWcJPGdmXTOeaEPIYtu5XQ==", | ||
| 1991 | "license": "MIT", | ||
| 1992 | "dependencies": { | ||
| 1993 | "@sindresorhus/fnv1a": "^3.1.0", | ||
| 1994 | "@workos-inc/node": "^9.0.0", | ||
| 1995 | "iron-session": "^8.0.4", | ||
| 1996 | "jose": "^5.10.0", | ||
| 1997 | "path-to-regexp": "^6.3.0", | ||
| 1998 | "valibot": "^1.2.0" | ||
| 1999 | }, | ||
| 2000 | "engines": { | ||
| 2001 | "node": ">=22.11.0" | ||
| 2002 | }, | ||
| 2003 | "peerDependencies": { | ||
| 2004 | "next": "^13.5.9 || ^14.2.26 || ^15.2.3 || ^16", | ||
| 2005 | "react": "^18.0 || ^19.0.0", | ||
| 2006 | "react-dom": "^18.0 || ^19.0.0" | ||
| 2007 | } | ||
| 2008 | }, | ||
| 2009 | "node_modules/@workos-inc/node": { | ||
| 2010 | "version": "9.3.1", | ||
| 2011 | "resolved": "https://registry.npmjs.org/@workos-inc/node/-/node-9.3.1.tgz", | ||
| 2012 | "integrity": "sha512-z6+V5lPAdEtJ2IklAsOUJWv5guihkMbtC8WHmLCKOJYLa/H0j9bRj/OmxTs7T+bzL2dhItxPRarhxlAAq/0jgQ==", | ||
| 2013 | "license": "MIT", | ||
| 2014 | "dependencies": { | ||
| 2015 | "eventemitter3": "^5.0.4" | ||
| 2016 | }, | ||
| 2017 | "engines": { | ||
| 2018 | "node": ">=22.11.0" | ||
| 2019 | } | ||
| 2020 | }, | ||
| 2021 | "node_modules/@xmldom/xmldom": { | 2070 | "node_modules/@xmldom/xmldom": { |
| 2022 | "version": "0.9.10", | 2071 | "version": "0.9.10", |
| 2023 | "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", | 2072 | "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", |
| @@ -2093,15 +2142,6 @@ · 69 unmodified lines | |||
| 2093 | "node": ">=18" | 2142 | "node": ">=18" |
| 2094 | } | 2143 | } |
| 2095 | }, | 2144 | }, |
| 2096 | "node_modules/cookie": { | ||
| 2097 | "version": "0.7.2", | ||
| 2098 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", | ||
| 2099 | "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", | ||
| 2100 | "license": "MIT", | ||
| 2101 | "engines": { | ||
| 2102 | "node": ">= 0.6" | ||
| 2103 | } | ||
| 2104 | }, | ||
| 2105 | "node_modules/csstype": { | 2145 | "node_modules/csstype": { |
| 2106 | "version": "3.2.3", | 2146 | "version": "3.2.3", |
| 2107 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", | 2147 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", |
| @@ -2109,6 +2149,15 @@ · 1 unmodified line | |||
| 2109 | "dev": true, | 2149 | "dev": true, |
| 2110 | "license": "MIT" | 2150 | "license": "MIT" |
| 2111 | }, | 2151 | }, |
| 2152 | "node_modules/dequal": { | ||
| 2153 | "version": "2.0.3", | ||
| 2154 | "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", | ||
| 2155 | "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", | ||
| 2156 | "license": "MIT", | ||
| 2157 | "engines": { | ||
| 2158 | "node": ">=6" | ||
| 2159 | } | ||
| 2160 | }, | ||
| 2112 | "node_modules/detect-libc": { | 2161 | "node_modules/detect-libc": { |
| 2113 | "version": "2.1.2", | 2162 | "version": "2.1.2", |
| 2114 | "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", | 2163 | "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", |
| @@ -2325,11 +2374,11 @@ · 210 unmodified lines | |||
| 2325 | "node": ">=6" | 2374 | "node": ">=6" |
| 2326 | } | 2375 | } |
| 2327 | }, | 2376 | }, |
| 2328 | "node_modules/eventemitter3": { | 2377 | "node_modules/fast-sha256": { |
| 2329 | "version": "5.0.4", | 2378 | "version": "1.3.0", |
| 2330 | "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", | 2379 | "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", |
| 2331 | "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", | 2380 | "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", |
| 2332 | "license": "MIT" | 2381 | "license": "Unlicense" |
| 2333 | }, | 2382 | }, |
| 2334 | "node_modules/fsevents": { | 2383 | "node_modules/fsevents": { |
| 2335 | "version": "2.3.3", | 2384 | "version": "2.3.3", |
| @@ -2368,6 +2417,12 @@ · 32 unmodified lines | |||
| 2368 | "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" | 2417 | "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" |
| 2369 | } | 2418 | } |
| 2370 | }, | 2419 | }, |
| 2420 | "node_modules/glob-to-regexp": { | ||
| 2421 | "version": "0.4.1", | ||
| 2422 | "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", | ||
| 2423 | "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", | ||
| 2424 | "license": "BSD-2-Clause" | ||
| 2425 | }, | ||
| 2371 | "node_modules/graceful-fs": { | 2426 | "node_modules/graceful-fs": { |
| 2372 | "version": "4.2.11", | 2427 | "version": "4.2.11", |
| 2373 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", | 2428 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", |
| @@ -2375,30 +2430,6 @@ · 1 unmodified line | |||
| 2375 | "dev": true, | 2430 | "dev": true, |
| 2376 | "license": "ISC" | 2431 | "license": "ISC" |
| 2377 | }, | 2432 | }, |
| 2378 | "node_modules/iron-session": { | ||
| 2379 | "version": "8.0.4", | ||
| 2380 | "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", | ||
| 2381 | "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", | ||
| 2382 | "funding": [ | ||
| 2383 | "https://github.com/sponsors/vvo", | ||
| 2384 | "https://github.com/sponsors/brc-dd" | ||
| 2385 | ], | ||
| 2386 | "license": "MIT", | ||
| 2387 | "dependencies": { | ||
| 2388 | "cookie": "^0.7.2", | ||
| 2389 | "iron-webcrypto": "^1.2.1", | ||
| 2390 | "uncrypto": "^0.1.3" | ||
| 2391 | } | ||
| 2392 | }, | ||
| 2393 | "node_modules/iron-webcrypto": { | ||
| 2394 | "version": "1.2.1", | ||
| 2395 | "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", | ||
| 2396 | "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", | ||
| 2397 | "license": "MIT", | ||
| 2398 | "funding": { | ||
| 2399 | "url": "https://github.com/sponsors/brc-dd" | ||
| 2400 | } | ||
| 2401 | }, | ||
| 2402 | "node_modules/jiti": { | 2433 | "node_modules/jiti": { |
| 2403 | "version": "2.7.0", | 2434 | "version": "2.7.0", |
| 2404 | "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", | 2435 | "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", |
| @@ -2409,13 +2440,13 @@ · 4 unmodified lines | |||
| 2409 | "jiti": "lib/jiti-cli.mjs" | 2440 | "jiti": "lib/jiti-cli.mjs" |
| 2410 | } | 2441 | } |
| 2411 | }, | 2442 | }, |
| 2412 | "node_modules/jose": { | 2443 | "node_modules/js-cookie": { |
| 2413 | "version": "5.10.0", | 2444 | "version": "3.0.7", |
| 2414 | "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", | 2445 | "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", |
| 2415 | "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", | 2446 | "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", |
| 2416 | "license": "MIT", | 2447 | "license": "MIT", |
| 2417 | "funding": { | 2448 | "engines": { |
| 2418 | "url": "https://github.com/sponsors/panva" | 2449 | "node": ">=20" |
| 2419 | } | 2450 | } |
| 2420 | }, | 2451 | }, |
| 2421 | "node_modules/lightningcss": { | 2452 | "node_modules/lightningcss": { |
| @@ -2825,12 +2856,6 @@ · 403 unmodified lines | |||
| 2825 | "node": "^10 || ^12 || >=14" | 2856 | "node": "^10 || ^12 || >=14" |
| 2826 | } | 2857 | } |
| 2827 | }, | 2858 | }, |
| 2828 | "node_modules/path-to-regexp": { | ||
| 2829 | "version": "6.3.0", | ||
| 2830 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", | ||
| 2831 | "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", | ||
| 2832 | "license": "MIT" | ||
| 2833 | }, | ||
| 2834 | "node_modules/picocolors": { | 2859 | "node_modules/picocolors": { |
| 2835 | "version": "1.1.1", | 2860 | "version": "1.1.1", |
| 2836 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | 2861 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", |
| @@ -3024,6 +3049,16 @@ · 187 unmodified lines | |||
| 3024 | "sre": "bin/sre" | 3049 | "sre": "bin/sre" |
| 3025 | } | 3050 | } |
| 3026 | }, | 3051 | }, |
| 3052 | "node_modules/standardwebhooks": { | ||
| 3053 | "version": "1.0.0", | ||
| 3054 | "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", | ||
| 3055 | "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", | ||
| 3056 | "license": "MIT", | ||
| 3057 | "dependencies": { | ||
| 3058 | "@stablelib/base64": "^1.0.0", | ||
| 3059 | "fast-sha256": "^1.3.0" | ||
| 3060 | } | ||
| 3061 | }, | ||
| 3027 | "node_modules/styled-jsx": { | 3062 | "node_modules/styled-jsx": { |
| 3028 | "version": "5.1.6", | 3063 | "version": "5.1.6", |
| 3029 | "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", | 3064 | "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", |
| @@ -3581,7 +3616,7 @@ · 551 unmodified lines | |||
| 3581 | "version": "6.0.3", | 3616 | "version": "6.0.3", |
| 3582 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", | 3617 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", |
| 3583 | "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", | 3618 | "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", |
| 3584 | "devOptional": true, | 3619 | "dev": true, |
| 3585 | "license": "Apache-2.0", | 3620 | "license": "Apache-2.0", |
| 3586 | "bin": { | 3621 | "bin": { |
| 3587 | "tsc": "bin/tsc", | 3622 | "tsc": "bin/tsc", |
| @@ -3591,12 +3626,6 @@ · 3 unmodified lines | |||
| 3591 | "node": ">=14.17" | 3626 | "node": ">=14.17" |
| 3592 | } | 3627 | } |
| 3593 | }, | 3628 | }, |
| 3594 | "node_modules/uncrypto": { | ||
| 3595 | "version": "0.1.3", | ||
| 3596 | "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", | ||
| 3597 | "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", | ||
| 3598 | "license": "MIT" | ||
| 3599 | }, | ||
| 3600 | "node_modules/undici-types": { | 3629 | "node_modules/undici-types": { |
| 3601 | "version": "7.24.6", | 3630 | "version": "7.24.6", |
| 3602 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", | 3631 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", |
| @@ -3604,20 +3633,6 @@ · 1 unmodified line | |||
| 3604 | "dev": true, | 3633 | "dev": true, |
| 3605 | "license": "MIT" | 3634 | "license": "MIT" |
| 3606 | }, | 3635 | }, |
| 3607 | "node_modules/valibot": { | ||
| 3608 | "version": "1.4.1", | ||
| 3609 | "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.1.tgz", | ||
| 3610 | "integrity": "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==", | ||
| 3611 | "license": "MIT", | ||
| 3612 | "peerDependencies": { | ||
| 3613 | "typescript": ">=5" | ||
| 3614 | }, | ||
| 3615 | "peerDependenciesMeta": { | ||
| 3616 | "typescript": { | ||
| 3617 | "optional": true | ||
| 3618 | } | ||
| 3619 | } | ||
| 3620 | }, | ||
| 3621 | "node_modules/wicked-good-xpath": { | 3636 | "node_modules/wicked-good-xpath": { |
| 3622 | "version": "1.3.0", | 3637 | "version": "1.3.0", |
| 3623 | "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", | 3638 | "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", |
| @@ -8,6 +8,7 @@ · 7 unmodified lines | |||
| 8 | "build": "next build", | 8 | "build": "next build", |
| 9 | "start": "next start", | 9 | "start": "next start", |
| 10 | "lint": "next lint", | 10 | "lint": "next lint", |
| 11 | "test:arithmetic": "tsx scripts/test-arithmetic.ts", | ||
| 11 | "db:generate": "drizzle-kit generate", | 12 | "db:generate": "drizzle-kit generate", |
| 12 | "db:migrate": "tsx scripts/migrate.ts", | 13 | "db:migrate": "tsx scripts/migrate.ts", |
| 13 | "db:seed": "tsx scripts/seed.ts", | 14 | "db:seed": "tsx scripts/seed.ts", |
| @@ -19,7 +20,7 @@ · 5 unmodified lines | |||
| 19 | }, | 20 | }, |
| 20 | "license": "MIT", | 21 | "license": "MIT", |
| 21 | "dependencies": { | 22 | "dependencies": { |
| 22 | "@workos-inc/authkit-nextjs": "latest", | 23 | "@clerk/nextjs": "^7.5.17", |
| 23 | "better-react-mathjax": "^2.1.0", | 24 | "better-react-mathjax": "^2.1.0", |
| 24 | "drizzle-orm": "^0.44.0", | 25 | "drizzle-orm": "^0.44.0", |
| 25 | "geist": "^1.4.1", | 26 | "geist": "^1.4.1", |
| @@ -0,0 +1,74 @@ | |||
| 1 | import { | ||
| 2 | arithmeticCategories, | ||
| 3 | checkArithmeticAnswer, | ||
| 4 | createSeededRng, | ||
| 5 | generateArithmeticProblem, | ||
| 6 | scoreArithmeticAttempt, | ||
| 7 | type ArithmeticSessionConfig, | ||
| 8 | type ArithmeticStar, | ||
| 9 | } from "../src/lib/arithmetic"; | ||
| 10 | |||
| 11 | function assert(condition: unknown, message: string): asserts condition { | ||
| 12 | if (!condition) { | ||
| 13 | throw new Error(message); | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | const stars: ArithmeticStar[] = [1, 2, 3, 4, 5]; | ||
| 18 | |||
| 19 | for (const category of arithmeticCategories) { | ||
| 20 | for (const star of stars) { | ||
| 21 | const config: ArithmeticSessionConfig = { | ||
| 22 | mode: "standard", | ||
| 23 | category: category.id, | ||
| 24 | submode: category.id === "roots-powers" ? "sqrt" : undefined, | ||
| 25 | star, | ||
| 26 | runMode: "60s", | ||
| 27 | magnitude: category.id.startsWith("big-") ? "thousands" : undefined, | ||
| 28 | }; | ||
| 29 | const rng = createSeededRng(star * 1000 + category.id.length); | ||
| 30 | |||
| 31 | for (let index = 0; index < 25; index += 1) { | ||
| 32 | const problem = generateArithmeticProblem(config, rng); | ||
| 33 | assert(problem.expression.length > 0, `${category.id} produced empty expression`); | ||
| 34 | assert(problem.canonicalAnswer.length > 0, `${category.id} produced empty answer`); | ||
| 35 | assert( | ||
| 36 | checkArithmeticAnswer(problem, problem.canonicalAnswer), | ||
| 37 | `${category.id} failed canonical answer check` | ||
| 38 | ); | ||
| 39 | assert( | ||
| 40 | scoreArithmeticAttempt({ correct: true, elapsedMs: 1000, problem }, config) > 0, | ||
| 41 | `${category.id} failed positive scoring` | ||
| 42 | ); | ||
| 43 | assert( | ||
| 44 | scoreArithmeticAttempt({ correct: false, elapsedMs: 1000, problem }, config) === 0, | ||
| 45 | `${category.id} failed incorrect scoring` | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | const fractionConfig: ArithmeticSessionConfig = { | ||
| 52 | mode: "standard", | ||
| 53 | category: "division", | ||
| 54 | star: 5, | ||
| 55 | runMode: "60s", | ||
| 56 | }; | ||
| 57 | const fractionProblem = { | ||
| 58 | ...generateArithmeticProblem(fractionConfig, createSeededRng(9)), | ||
| 59 | canonicalAnswer: "1/2", | ||
| 60 | answerFormat: "fraction" as const, | ||
| 61 | }; | ||
| 62 | |||
| 63 | assert(checkArithmeticAnswer(fractionProblem, "0.5"), "0.5 should equal 1/2"); | ||
| 64 | assert(checkArithmeticAnswer(fractionProblem, "2/4"), "2/4 should equal 1/2"); | ||
| 65 | assert(!checkArithmeticAnswer(fractionProblem, "0.6"), "0.6 should not equal 1/2"); | ||
| 66 | |||
| 67 | const binaryProblem = generateArithmeticProblem( | ||
| 68 | { mode: "standard", category: "binary", star: 2, runMode: "60s" }, | ||
| 69 | createSeededRng(12) | ||
| 70 | ); | ||
| 71 | assert(/^[01]+$/.test(binaryProblem.canonicalAnswer), "binary answer should be base two"); | ||
| 72 | assert(checkArithmeticAnswer(binaryProblem, binaryProblem.canonicalAnswer), "binary answer failed"); | ||
| 73 | |||
| 74 | console.log("arithmetic tests passed"); | ||
| @@ -1,12 +1,9 @@ | |||
| 1 | import { Header } from "@/components/navigation/header"; | ||
| 2 | |||
| 3 | export default function AppLayout({ children }: { children: React.ReactNode }) { | 1 | export default function AppLayout({ children }: { children: React.ReactNode }) { |
| 4 | return ( | 2 | return ( |
| 5 | <div | 3 | <div |
| 6 | className="min-h-screen flex flex-col" | 4 | className="min-h-screen flex flex-col" |
| 7 | style={{ background: "var(--bg-canvas)" }} | 5 | style={{ background: "var(--bg-canvas)" }} |
| 8 | > | 6 | > |
| 9 | <Header /> | ||
| 10 | <main className="flex-1">{children}</main> | 7 | <main className="flex-1">{children}</main> |
| 11 | </div> | 8 | </div> |
| 12 | ); | 9 | ); |
| @@ -0,0 +1,102 @@ | |||
| 1 | import { notFound } from "next/navigation"; | ||
| 2 | import { SurfaceNav } from "@/components/navigation/surfaceNav"; | ||
| 3 | import { getGuide, guides } from "@/lib/guides"; | ||
| 4 | |||
| 5 | interface Props { | ||
| 6 | params: Promise<{ slug: string }>; | ||
| 7 | } | ||
| 8 | |||
| 9 | export function generateStaticParams() { | ||
| 10 | return guides.map((guide) => ({ slug: guide.slug })); | ||
| 11 | } | ||
| 12 | |||
| 13 | export default async function GuidePage({ params }: Props) { | ||
| 14 | const { slug } = await params; | ||
| 15 | const guide = getGuide(slug); | ||
| 16 | |||
| 17 | if (!guide) { | ||
| 18 | notFound(); | ||
| 19 | } | ||
| 20 | |||
| 21 | return ( | ||
| 22 | <> | ||
| 23 | <style>{guidePageStyles}</style> | ||
| 24 | <main className="guide-doc-shell"> | ||
| 25 | <div className="guide-doc-frame"> | ||
| 26 | <SurfaceNav variant="light" /> | ||
| 27 | |||
| 28 | <article className="guide-doc-card"> | ||
| 29 | <span className="guide-doc-dot" /> | ||
| 30 | <header className="guide-doc-header"> | ||
| 31 | <div className="guide-doc-meta"> | ||
| 32 | <span>{guide.source}</span> | ||
| 33 | <span>{guide.readTime}</span> | ||
| 34 | <span>{guide.updatedAt}</span> | ||
| 35 | </div> | ||
| 36 | <h1>{guide.title}</h1> | ||
| 37 | <p>{guide.dek}</p> | ||
| 38 | <div className="guide-doc-author">Created by {guide.author}</div> | ||
| 39 | </header> | ||
| 40 | |||
| 41 | <div className="guide-doc-body"> | ||
| 42 | {guide.sections.map((section) => ( | ||
| 43 | <section key={section.heading}> | ||
| 44 | <h2>{section.heading}</h2> | ||
| 45 | {section.body} | ||
| 46 | </section> | ||
| 47 | ))} | ||
| 48 | </div> | ||
| 49 | </article> | ||
| 50 | </div> | ||
| 51 | </main> | ||
| 52 | </> | ||
| 53 | ); | ||
| 54 | } | ||
| 55 | |||
| 56 | const guidePageStyles = ` | ||
| 57 | .guide-doc-shell { | ||
| 58 | --surface-bg: #ffffff; | ||
| 59 | --surface-card: #ffffff; | ||
| 60 | --surface-text: #303030; | ||
| 61 | --surface-strong: #111; | ||
| 62 | --surface-muted: #555; | ||
| 63 | --surface-faint: #9a9a9a; | ||
| 64 | --surface-line: #e3e3e3; | ||
| 65 | min-height: 100vh; | ||
| 66 | background: var(--surface-bg); | ||
| 67 | color: var(--surface-text); | ||
| 68 | padding: 28px 5vw 86px; | ||
| 69 | } | ||
| 70 | |||
| 71 | .dark .guide-doc-shell { | ||
| 72 | --surface-bg: #080808; | ||
| 73 | --surface-card: #111; | ||
| 74 | --surface-text: #d6d3cc; | ||
| 75 | --surface-strong: #fff; | ||
| 76 | --surface-muted: #aaa; | ||
| 77 | --surface-faint: #707070; | ||
| 78 | --surface-line: #242424; | ||
| 79 | } | ||
| 80 | |||
| 81 | .guide-doc-shell a { color: inherit; text-decoration: none; } | ||
| 82 | .guide-doc-frame { margin: 0 auto; max-width: 1180px; } | ||
| 83 | .guide-doc-card { background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 8px; box-shadow: 0 24px 58px rgba(0,0,0,0.09); margin: 52px auto 0; max-width: 760px; min-height: 860px; padding: clamp(34px, 6vw, 64px); position: relative; } | ||
| 84 | .dark .guide-doc-card { box-shadow: none; } | ||
| 85 | .guide-doc-dot { background: #aeb4bf; border-radius: 50%; height: 9px; position: absolute; right: 16px; top: 16px; width: 9px; } | ||
| 86 | .guide-doc-meta { color: var(--surface-faint); display: flex; flex-wrap: wrap; gap: 14px; font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.08em; margin-bottom: 28px; text-transform: uppercase; } | ||
| 87 | .guide-doc-header h1 { color: var(--surface-strong); font-size: clamp(42px, 7vw, 68px); font-weight: 650; letter-spacing: 0; line-height: 0.98; margin: 0; } | ||
| 88 | .guide-doc-header p { color: var(--surface-muted); font-size: 18px; line-height: 1.55; margin: 22px 0 0; } | ||
| 89 | .guide-doc-author { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.08em; margin-top: 24px; text-transform: uppercase; } | ||
| 90 | .guide-doc-body { margin-top: 56px; } | ||
| 91 | .guide-doc-body section { border-top: 1px solid var(--surface-line); padding: 30px 0 4px; } | ||
| 92 | .guide-doc-body h2 { color: var(--surface-strong); font-size: 28px; font-weight: 650; letter-spacing: 0; line-height: 1.12; margin: 0 0 14px; } | ||
| 93 | .guide-doc-body p { color: var(--surface-text); font-size: 16px; line-height: 1.72; margin: 0 0 16px; } | ||
| 94 | .guide-doc-body ul { color: var(--surface-text); margin: 0 0 16px; padding-left: 20px; } | ||
| 95 | .guide-doc-body li { margin: 6px 0; } | ||
| 96 | .guide-doc-body mjx-container[jax="CHTML"][display="true"] { margin: 26px 0; overflow-x: auto; overflow-y: hidden; padding: 10px 0; } | ||
| 97 | @media (max-width: 720px) { | ||
| 98 | .guide-doc-shell { padding: 28px 16px 56px; } | ||
| 99 | .guide-doc-card { margin-top: 34px; padding: 34px 24px; } | ||
| 100 | .guide-doc-meta { gap: 10px; } | ||
| 101 | } | ||
| 102 | `; | ||
| @@ -1,16 +1,95 @@ | |||
| 1 | export default function PathsPage() { | 1 | import { SurfaceNav } from "@/components/navigation/surfaceNav"; |
| 2 | import { guides } from "@/lib/guides"; | ||
| 3 | |||
| 4 | export default function GuidesPage() { | ||
| 2 | return ( | 5 | return ( |
| 3 | <div className="max-w-4xl mx-auto px-6 py-12"> | 6 | <> |
| 4 | <h1 | 7 | <style>{guideIndexStyles}</style> |
| 5 | className="text-2xl font-semibold mb-4" | 8 | <main className="guides-shell"> |
| 6 | style={{ color: "var(--text-primary)" }} | 9 | <div className="guides-frame"> |
| 7 | > | 10 | <SurfaceNav variant="light" /> |
| 8 | Textbook Paths | 11 | |
| 9 | </h1> | 12 | <header className="guides-hero"> |
| 10 | <p style={{ color: "var(--text-secondary)" }}> | 13 | <p>SumTex Guides</p> |
| 11 | Work through a textbook from start to finish. Each path contains all | 14 | <h1>Guides</h1> |
| 12 | problems from a specific text with synopses and page references. | 15 | <div> |
| 13 | </p> | 16 | Platform and user-authored documents for algebra, calculus, analysis, and proof technique. |
| 14 | </div> | 17 | </div> |
| 18 | </header> | ||
| 19 | |||
| 20 | <section className="guide-board" aria-label="Guide documents"> | ||
| 21 | {guides.map((guide, index) => ( | ||
| 22 | <a key={guide.slug} href={`/paths/${guide.slug}`} className="guide-card"> | ||
| 23 | <span className={`guide-dot guide-dot-${index % 3}`} /> | ||
| 24 | <div className="guide-card-meta"> | ||
| 25 | <span>{guide.source}</span> | ||
| 26 | <span>{guide.readTime}</span> | ||
| 27 | </div> | ||
| 28 | <h2>{guide.title}</h2> | ||
| 29 | <p>{guide.dek}</p> | ||
| 30 | <div className="guide-card-footer"> | ||
| 31 | <span>Created by {guide.author}</span> | ||
| 32 | <span>{guide.level}</span> | ||
| 33 | </div> | ||
| 34 | </a> | ||
| 35 | ))} | ||
| 36 | </section> | ||
| 37 | </div> | ||
| 38 | </main> | ||
| 39 | </> | ||
| 15 | ); | 40 | ); |
| 16 | } | 41 | } |
| 42 | |||
| 43 | const guideIndexStyles = ` | ||
| 44 | .guides-shell { | ||
| 45 | --surface-bg: #ffffff; | ||
| 46 | --surface-bg-soft: #f7f7f7; | ||
| 47 | --surface-card: #ffffff; | ||
| 48 | --surface-text: #171717; | ||
| 49 | --surface-strong: #000; | ||
| 50 | --surface-muted: #555; | ||
| 51 | --surface-faint: #9a9a9a; | ||
| 52 | --surface-line: #e3e3e3; | ||
| 53 | min-height: 100vh; | ||
| 54 | background: var(--surface-bg); | ||
| 55 | color: var(--surface-text); | ||
| 56 | padding: 28px 5vw 76px; | ||
| 57 | } | ||
| 58 | |||
| 59 | .dark .guides-shell { | ||
| 60 | --surface-bg: #080808; | ||
| 61 | --surface-bg-soft: #101010; | ||
| 62 | --surface-card: #111; | ||
| 63 | --surface-text: #f4f2ee; | ||
| 64 | --surface-strong: #fff; | ||
| 65 | --surface-muted: #a0a0a0; | ||
| 66 | --surface-faint: #6d6d6d; | ||
| 67 | --surface-line: #242424; | ||
| 68 | } | ||
| 69 | |||
| 70 | .guides-shell a { color: inherit; text-decoration: none; } | ||
| 71 | .guides-frame { margin: 0 auto; max-width: 1440px; } | ||
| 72 | .guides-hero { margin: 52px 0 58px; max-width: 820px; } | ||
| 73 | .guides-hero p { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.18em; margin: 0 0 22px; text-transform: uppercase; } | ||
| 74 | .guides-hero h1 { color: var(--surface-strong); font-size: clamp(52px, 7vw, 104px); font-weight: 400; letter-spacing: 0; line-height: 0.96; margin: 0; } | ||
| 75 | .guides-hero div { color: var(--surface-muted); font-size: 18px; line-height: 1.55; margin-top: 24px; max-width: 660px; } | ||
| 76 | .guide-board { align-items: start; display: grid; gap: 24px; grid-template-columns: repeat(3, minmax(0, 1fr)); } | ||
| 77 | .guide-card { background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 8px; box-shadow: 0 18px 42px rgba(0,0,0,0.08); display: flex; flex-direction: column; min-height: 520px; padding: 34px 28px 26px; position: relative; transition: border-color 160ms ease, transform 160ms ease; } | ||
| 78 | .dark .guide-card { box-shadow: none; } | ||
| 79 | .guide-card:hover { border-color: var(--surface-muted); transform: translateY(-2px); } | ||
| 80 | .guide-dot { border-radius: 50%; height: 9px; position: absolute; right: 14px; top: 14px; width: 9px; } | ||
| 81 | .guide-dot-0 { background: #6fd88c; } | ||
| 82 | .guide-dot-1 { background: #aeb4bf; } | ||
| 83 | .guide-dot-2 { background: #111; } | ||
| 84 | .dark .guide-dot-2 { background: #f4f2ee; } | ||
| 85 | .guide-card-meta, .guide-card-footer { color: var(--surface-faint); display: flex; font-family: var(--font-mono); font-size: 12px; justify-content: space-between; letter-spacing: 0.08em; text-transform: uppercase; } | ||
| 86 | .guide-card h2 { color: var(--surface-strong); font-size: clamp(28px, 3vw, 42px); font-weight: 600; letter-spacing: 0; line-height: 1.05; margin: 44px 0 18px; } | ||
| 87 | .guide-card p { color: var(--surface-muted); font-size: 15px; line-height: 1.55; margin: 0; } | ||
| 88 | .guide-card-footer { margin-top: auto; padding-top: 34px; } | ||
| 89 | @media (max-width: 1100px) { .guide-board { grid-template-columns: repeat(2, minmax(0, 1fr)); } } | ||
| 90 | @media (max-width: 720px) { | ||
| 91 | .guides-shell { padding: 28px 20px 56px; } | ||
| 92 | .guide-board { grid-template-columns: 1fr; } | ||
| 93 | .guide-card { min-height: 430px; } | ||
| 94 | } | ||
| 95 | `; | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | import { MentalArithmeticPlay } from "@/components/arithmetic/mentalArithmeticPlay"; | ||
| 2 | |||
| 3 | export default function MentalArithmeticPage() { | ||
| 4 | return <MentalArithmeticPlay />; | ||
| 5 | } | ||
| @@ -1,51 +1,108 @@ | |||
| 1 | import { SurfaceNav } from "@/components/navigation/surfaceNav"; | ||
| 2 | import { arithmeticCategories } from "@/lib/arithmetic"; | ||
| 3 | |||
| 4 | const arithmeticModes = arithmeticCategories.map((category) => ({ | ||
| 5 | title: category.label, | ||
| 6 | status: "Live", | ||
| 7 | href: `/play/mental-arithmetic?category=${category.id}&star=2`, | ||
| 8 | description: category.description, | ||
| 9 | detail: category.id === "roots-powers" ? "sqrt n / n^2 / n^3" : "1 to 5 stars", | ||
| 10 | })); | ||
| 11 | |||
| 1 | export default function PlayPage() { | 12 | export default function PlayPage() { |
| 2 | return ( | 13 | return ( |
| 3 | <div className="max-w-4xl mx-auto px-6 py-12"> | 14 | <> |
| 4 | <h1 | 15 | <style>{playHubStyles}</style> |
| 5 | className="text-2xl font-semibold mb-4" | 16 | <div className="play-shell"> |
| 6 | style={{ color: "var(--text-primary)" }} | 17 | <div className="play-frame"> |
| 7 | > | 18 | <SurfaceNav variant="dark" /> |
| 8 | Play | 19 | |
| 9 | </h1> | 20 | <header className="play-hero"> |
| 10 | <p style={{ color: "var(--text-secondary)" }}> | 21 | <div> |
| 11 | Select a mode to begin solving problems. | 22 | <p className="play-kicker">SumTex Play</p> |
| 12 | </p> | 23 | <h1>Modes of Arithmetic</h1> |
| 13 | <div className="grid grid-cols-2 gap-6 mt-8"> | 24 | <p> |
| 14 | <div | 25 | Choose the drill type first. Stars, timing, custom settings, and keypad entry stay inside the run page. |
| 15 | className="p-6 rounded-lg border cursor-pointer transition-colors" | 26 | </p> |
| 16 | style={{ | 27 | </div> |
| 17 | background: "var(--bg-primary)", | 28 | <a className="play-cta" href="/play/mental-arithmetic?category=addition&star=2"> |
| 18 | borderColor: "var(--border-color)", | 29 | Start addition |
| 19 | }} | 30 | </a> |
| 20 | > | 31 | </header> |
| 21 | <h2 | 32 | |
| 22 | className="text-lg font-medium mb-2" | 33 | <section className="play-grid" aria-label="Arithmetic modes"> |
| 23 | style={{ color: "var(--text-primary)" }} | 34 | {arithmeticModes.map((mode, index) => ( |
| 24 | > | 35 | <a key={mode.title} className="play-mode live" href={mode.href}> |
| 25 | Timed | 36 | <div className="play-mode-index">{String(index + 1).padStart(2, "0")}</div> |
| 26 | </h2> | 37 | <div className="play-mode-body"> |
| 27 | <p className="text-sm" style={{ color: "var(--text-secondary)" }}> | 38 | <div className="play-mode-top"> |
| 28 | 3 minutes. Solve as many problems as you can. 1.5x XP multiplier. | 39 | <h2>{mode.title}</h2> |
| 29 | </p> | 40 | <span className="play-status live">{mode.status}</span> |
| 30 | </div> | 41 | </div> |
| 31 | <div | 42 | <p>{mode.description}</p> |
| 32 | className="p-6 rounded-lg border cursor-pointer transition-colors" | 43 | <div className="play-mode-detail">{mode.detail}</div> |
| 33 | style={{ | 44 | </div> |
| 34 | background: "var(--bg-primary)", | 45 | </a> |
| 35 | borderColor: "var(--border-color)", | 46 | ))} |
| 36 | }} | 47 | </section> |
| 37 | > | ||
| 38 | <h2 | ||
| 39 | className="text-lg font-medium mb-2" | ||
| 40 | style={{ color: "var(--text-primary)" }} | ||
| 41 | > | ||
| 42 | Zen | ||
| 43 | </h2> | ||
| 44 | <p className="text-sm" style={{ color: "var(--text-secondary)" }}> | ||
| 45 | No timer. Work through problems at your own pace. | ||
| 46 | </p> | ||
| 47 | </div> | 48 | </div> |
| 48 | </div> | 49 | </div> |
| 49 | </div> | 50 | </> |
| 50 | ); | 51 | ); |
| 51 | } | 52 | } |
| 53 | |||
| 54 | const playHubStyles = ` | ||
| 55 | .play-shell { | ||
| 56 | --surface-bg: #ffffff; | ||
| 57 | --surface-bg-soft: #f6f6f6; | ||
| 58 | --surface-text: #171717; | ||
| 59 | --surface-strong: #000000; | ||
| 60 | --surface-muted: #555555; | ||
| 61 | --surface-faint: #8a8a8a; | ||
| 62 | --surface-line: #dedede; | ||
| 63 | --surface-pill: #f7f7f7; | ||
| 64 | min-height: 100vh; | ||
| 65 | background: var(--surface-bg); | ||
| 66 | color: var(--surface-text); | ||
| 67 | padding: 28px 5vw 70px; | ||
| 68 | } | ||
| 69 | |||
| 70 | .dark .play-shell { | ||
| 71 | --surface-bg: #080808; | ||
| 72 | --surface-bg-soft: #0d0d0d; | ||
| 73 | --surface-text: #f4f2ee; | ||
| 74 | --surface-strong: #f5f3ef; | ||
| 75 | --surface-muted: #8d8d8d; | ||
| 76 | --surface-faint: #777; | ||
| 77 | --surface-line: #242424; | ||
| 78 | --surface-pill: #151515; | ||
| 79 | } | ||
| 80 | |||
| 81 | .play-shell a { color: inherit; text-decoration: none; } | ||
| 82 | .play-frame { margin: 0 auto; max-width: 1440px; } | ||
| 83 | .play-hero { align-items: flex-start; display: flex; gap: 32px; justify-content: space-between; margin: 0 0 62px; } | ||
| 84 | .play-kicker { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.18em; line-height: 1; margin: 0 0 22px; text-transform: uppercase; } | ||
| 85 | .play-hero h1 { color: var(--surface-strong); font-size: clamp(52px, 7vw, 108px); font-weight: 400; letter-spacing: 0; line-height: 0.95; margin: 0; } | ||
| 86 | .play-hero p { color: var(--surface-muted); font-size: 18px; line-height: 1.5; margin: 24px 0 0; max-width: 680px; } | ||
| 87 | .play-cta { align-items: center; background: transparent; border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); display: inline-flex; font-family: var(--font-mono); font-size: 13px; font-weight: 600; justify-content: center; letter-spacing: 0.14em; min-height: 54px; padding: 0 28px; text-transform: uppercase; white-space: nowrap; } | ||
| 88 | .play-cta:hover { background: var(--surface-pill); border-color: var(--surface-muted); } | ||
| 89 | .play-grid { border-top: 1px solid var(--surface-line); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } | ||
| 90 | .play-mode { border-bottom: 1px solid var(--surface-line); color: var(--surface-text); display: grid; gap: 28px; grid-template-columns: 56px 1fr; min-height: 220px; padding: 34px 34px 32px 0; position: relative; } | ||
| 91 | .play-mode:nth-child(odd) { border-right: 1px solid var(--surface-line); padding-right: 44px; } | ||
| 92 | .play-mode:nth-child(even) { padding-left: 44px; } | ||
| 93 | .play-mode.live:hover { background: var(--surface-bg-soft); } | ||
| 94 | .play-mode-index { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.14em; padding-top: 5px; } | ||
| 95 | .play-mode-top { align-items: center; display: flex; gap: 18px; justify-content: space-between; } | ||
| 96 | .play-mode h2 { color: var(--surface-strong); font-size: clamp(28px, 3vw, 46px); font-weight: 400; letter-spacing: 0; line-height: 1.08; margin: 0; } | ||
| 97 | .play-mode p { color: var(--surface-muted); font-size: 17px; line-height: 1.5; margin: 30px 0 0; max-width: 540px; } | ||
| 98 | .play-mode-detail { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; margin-top: 32px; text-transform: uppercase; } | ||
| 99 | .play-status { border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-faint); flex: 0 0 auto; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.12em; padding: 7px 10px; text-transform: uppercase; } | ||
| 100 | .play-status.live { border-color: var(--surface-strong); color: var(--surface-strong); } | ||
| 101 | |||
| 102 | @media (max-width: 900px) { | ||
| 103 | .play-shell { padding: 28px 20px 56px; } | ||
| 104 | .play-hero { align-items: stretch; flex-direction: column; margin-bottom: 40px; } | ||
| 105 | .play-grid { grid-template-columns: 1fr; } | ||
| 106 | .play-mode, .play-mode:nth-child(even), .play-mode:nth-child(odd) { border-right: 0; grid-template-columns: 42px 1fr; min-height: 190px; padding: 28px 0; } | ||
| 107 | } | ||
| 108 | `; | ||
| @@ -1,15 +1,139 @@ | |||
| 1 | import { SurfaceNav } from "@/components/navigation/surfaceNav"; | ||
| 2 | |||
| 3 | const modes = [ | ||
| 4 | { | ||
| 5 | title: "Algebra", | ||
| 6 | status: "Planned", | ||
| 7 | description: "Pre-algebra, intermediate algebra, and advanced algebra in one progression.", | ||
| 8 | detail: "Pre / Intermediate / Advanced", | ||
| 9 | }, | ||
| 10 | { | ||
| 11 | title: "Trigonometry", | ||
| 12 | status: "Planned", | ||
| 13 | description: "Angles, identities, equations, graphs, and applied triangle work.", | ||
| 14 | detail: "Identities / Graphs / Applications", | ||
| 15 | }, | ||
| 16 | { | ||
| 17 | title: "Geometry", | ||
| 18 | status: "Planned", | ||
| 19 | description: "Synthetic, analytic, and proof-oriented geometry problem sets.", | ||
| 20 | detail: "Euclidean / Coordinate / Proofs", | ||
| 21 | }, | ||
| 22 | { | ||
| 23 | title: "Calculus", | ||
| 24 | status: "Planned", | ||
| 25 | description: "Calculus 1, 2, and 3 tracks for limits, integrals, series, and multivariable work.", | ||
| 26 | detail: "I / II / III", | ||
| 27 | }, | ||
| 28 | { | ||
| 29 | title: "Real Analysis", | ||
| 30 | status: "Planned", | ||
| 31 | description: "Sequences, topology of the line, continuity, differentiation, integration, and metric spaces.", | ||
| 32 | detail: "Proof-heavy", | ||
| 33 | }, | ||
| 34 | { | ||
| 35 | title: "Complex Analysis", | ||
| 36 | status: "Planned", | ||
| 37 | description: "Holomorphic functions, residues, contour integration, and conformal maps.", | ||
| 38 | detail: "Functions / Integrals / Residues", | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | title: "Function Analysis", | ||
| 42 | status: "Planned", | ||
| 43 | description: "Function spaces, operators, norms, convergence, and structural problem drills.", | ||
| 44 | detail: "Spaces / Operators / Norms", | ||
| 45 | }, | ||
| 46 | ]; | ||
| 47 | |||
| 1 | export default function ProblemsPage() { | 48 | export default function ProblemsPage() { |
| 2 | return ( | 49 | return ( |
| 3 | <div className="max-w-4xl mx-auto px-6 py-12"> | 50 | <> |
| 4 | <h1 | 51 | <style>{problemHubStyles}</style> |
| 5 | className="text-2xl font-semibold mb-4" | 52 | <div className="problem-shell"> |
| 6 | style={{ color: "var(--text-primary)" }} | 53 | <div className="problem-frame"> |
| 7 | > | 54 | <SurfaceNav variant="dark" /> |
| 8 | Problems | 55 | |
| 9 | </h1> | 56 | <header className="problem-hero"> |
| 10 | <p style={{ color: "var(--text-secondary)" }}> | 57 | <div> |
| 11 | Browse problems by subject, difficulty, or textbook. | 58 | <p className="problem-kicker">SumTex Problems</p> |
| 12 | </p> | 59 | <h1>Choose a subject</h1> |
| 13 | </div> | 60 | <p> |
| 61 | Problem tracks begin here. Arithmetic lives under Play; broader math problem sets start with Algebra. | ||
| 62 | </p> | ||
| 63 | </div> | ||
| 64 | </header> | ||
| 65 | |||
| 66 | <section className="problem-grid" aria-label="Problem subjects"> | ||
| 67 | {modes.map((mode, index) => ( | ||
| 68 | <div key={mode.title} className="problem-mode disabled" aria-disabled="true"> | ||
| 69 | <div className="problem-mode-index">{String(index + 1).padStart(2, "0")}</div> | ||
| 70 | <div className="problem-mode-body"> | ||
| 71 | <div className="problem-mode-top"> | ||
| 72 | <h2>{mode.title}</h2> | ||
| 73 | <span className="problem-status">{mode.status}</span> | ||
| 74 | </div> | ||
| 75 | <p>{mode.description}</p> | ||
| 76 | <div className="problem-mode-detail">{mode.detail}</div> | ||
| 77 | </div> | ||
| 78 | </div> | ||
| 79 | ))} | ||
| 80 | </section> | ||
| 81 | </div> | ||
| 82 | </div> | ||
| 83 | </> | ||
| 14 | ); | 84 | ); |
| 15 | } | 85 | } |
| 86 | |||
| 87 | const problemHubStyles = ` | ||
| 88 | .problem-shell { | ||
| 89 | --surface-bg: #ffffff; | ||
| 90 | --surface-bg-soft: #f6f6f6; | ||
| 91 | --surface-text: #171717; | ||
| 92 | --surface-strong: #000000; | ||
| 93 | --surface-muted: #555555; | ||
| 94 | --surface-faint: #8a8a8a; | ||
| 95 | --surface-line: #dedede; | ||
| 96 | --surface-pill: #f7f7f7; | ||
| 97 | min-height: 100vh; | ||
| 98 | background: var(--surface-bg); | ||
| 99 | color: var(--surface-text); | ||
| 100 | padding: 28px 5vw 70px; | ||
| 101 | } | ||
| 102 | |||
| 103 | .dark .problem-shell { | ||
| 104 | --surface-bg: #080808; | ||
| 105 | --surface-bg-soft: #0d0d0d; | ||
| 106 | --surface-text: #f4f2ee; | ||
| 107 | --surface-strong: #f5f3ef; | ||
| 108 | --surface-muted: #8d8d8d; | ||
| 109 | --surface-faint: #777; | ||
| 110 | --surface-line: #242424; | ||
| 111 | --surface-pill: #151515; | ||
| 112 | } | ||
| 113 | |||
| 114 | .problem-shell a { color: inherit; text-decoration: none; } | ||
| 115 | .problem-frame { margin: 0 auto; max-width: 1440px; } | ||
| 116 | .problem-hero { align-items: flex-start; display: flex; gap: 32px; justify-content: space-between; margin: 0 0 62px; } | ||
| 117 | .problem-kicker { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.18em; line-height: 1; margin: 0 0 22px; text-transform: uppercase; } | ||
| 118 | .problem-hero h1 { color: var(--surface-strong); font-size: clamp(52px, 7vw, 108px); font-weight: 400; letter-spacing: 0; line-height: 0.95; margin: 0; } | ||
| 119 | .problem-hero p { color: var(--surface-muted); font-size: 18px; line-height: 1.5; margin: 24px 0 0; max-width: 680px; } | ||
| 120 | .problem-grid { border-top: 1px solid var(--surface-line); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } | ||
| 121 | .problem-mode { border-bottom: 1px solid var(--surface-line); color: var(--surface-text); display: grid; gap: 28px; grid-template-columns: 56px 1fr; min-height: 220px; padding: 34px 34px 32px 0; position: relative; } | ||
| 122 | .problem-mode:nth-child(odd) { border-right: 1px solid var(--surface-line); padding-right: 44px; } | ||
| 123 | .problem-mode:nth-child(even) { padding-left: 44px; } | ||
| 124 | .problem-mode.disabled { cursor: default; } | ||
| 125 | .problem-mode-index { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.14em; padding-top: 5px; } | ||
| 126 | .problem-mode-top { align-items: center; display: flex; gap: 18px; justify-content: space-between; } | ||
| 127 | .problem-mode h2 { color: var(--surface-strong); font-size: clamp(28px, 3vw, 46px); font-weight: 400; letter-spacing: 0; line-height: 1.08; margin: 0; } | ||
| 128 | .problem-mode p { color: var(--surface-muted); font-size: 17px; line-height: 1.5; margin: 30px 0 0; max-width: 540px; } | ||
| 129 | .problem-mode-detail { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; margin-top: 32px; text-transform: uppercase; } | ||
| 130 | .problem-status { border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-faint); flex: 0 0 auto; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.12em; padding: 7px 10px; text-transform: uppercase; } | ||
| 131 | .problem-mode-body { opacity: 0.72; } | ||
| 132 | |||
| 133 | @media (max-width: 900px) { | ||
| 134 | .problem-shell { padding: 28px 20px 56px; } | ||
| 135 | .problem-hero { align-items: stretch; flex-direction: column; margin-bottom: 40px; } | ||
| 136 | .problem-grid { grid-template-columns: 1fr; } | ||
| 137 | .problem-mode, .problem-mode:nth-child(even), .problem-mode:nth-child(odd) { border-right: 0; grid-template-columns: 42px 1fr; min-height: 190px; padding: 28px 0; } | ||
| 138 | } | ||
| 139 | `; | ||
| @@ -1,15 +1,377 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useMemo, useState } from "react"; | ||
| 4 | import Link from "next/link"; | ||
| 5 | import { SurfaceNav } from "@/components/navigation/surfaceNav"; | ||
| 6 | |||
| 7 | type FeedTab = "activity" | "play" | "problems" | "guides"; | ||
| 8 | type FeedKind = "play" | "problems" | "guides"; | ||
| 9 | |||
| 10 | interface FeedItem { | ||
| 11 | title: string; | ||
| 12 | meta: string; | ||
| 13 | action: "published" | "practiced" | "completed" | "reviewed" | "started" | "solved"; | ||
| 14 | kind: FeedKind; | ||
| 15 | subtype: string; | ||
| 16 | score?: string; | ||
| 17 | href?: string; | ||
| 18 | } | ||
| 19 | |||
| 20 | const stats = [ | ||
| 21 | { label: "Sessions", value: "128", mark: "S", tone: "green" }, | ||
| 22 | { label: "Attempts", value: "4,920", mark: "A" }, | ||
| 23 | { label: "Accuracy", value: "91%", mark: "%", tone: "green" }, | ||
| 24 | { label: "Best Run", value: "742", mark: "#" }, | ||
| 25 | { label: "Guides", value: "3", mark: "G", tone: "green" }, | ||
| 26 | ]; | ||
| 27 | |||
| 28 | const badges = [ | ||
| 29 | { label: "Founder", value: "SumTex", tone: "strong" }, | ||
| 30 | { label: "Developer", value: "Core", tone: "green" }, | ||
| 31 | { label: "Account age", value: "6 months" }, | ||
| 32 | { label: "Status", value: "Active" }, | ||
| 33 | { label: "Role", value: "Platform" }, | ||
| 34 | ]; | ||
| 35 | |||
| 36 | const feedItems: FeedItem[] = [ | ||
| 37 | { | ||
| 38 | title: "Completed a 5-star subtraction run", | ||
| 39 | meta: "48/52 correct / mental arithmetic", | ||
| 40 | action: "completed", | ||
| 41 | kind: "play", | ||
| 42 | subtype: "subtraction", | ||
| 43 | score: "742", | ||
| 44 | href: "/play/mental-arithmetic?category=subtraction&star=5", | ||
| 45 | }, | ||
| 46 | { | ||
| 47 | title: "Published A field guide to algebraic factoring", | ||
| 48 | meta: "Guide / algebra / 9 min", | ||
| 49 | action: "published", | ||
| 50 | kind: "guides", | ||
| 51 | subtype: "algebra", | ||
| 52 | score: "new", | ||
| 53 | href: "/paths/algebraic-factoring-field-guide", | ||
| 54 | }, | ||
| 55 | { | ||
| 56 | title: "Practiced big multiply in millions", | ||
| 57 | meta: "31/34 correct / thousands to millions", | ||
| 58 | action: "practiced", | ||
| 59 | kind: "play", | ||
| 60 | subtype: "big multiply", | ||
| 61 | score: "611", | ||
| 62 | href: "/play/mental-arithmetic?category=big-multiply&star=4", | ||
| 63 | }, | ||
| 64 | { | ||
| 65 | title: "Solved an intermediate algebra problem set", | ||
| 66 | meta: "Problems / algebra / 12 solved", | ||
| 67 | action: "solved", | ||
| 68 | kind: "problems", | ||
| 69 | subtype: "algebra", | ||
| 70 | score: "12", | ||
| 71 | href: "/problems", | ||
| 72 | }, | ||
| 73 | { | ||
| 74 | title: "Reviewed Limits without mysticism", | ||
| 75 | meta: "Guide / calculus / epsilon delta", | ||
| 76 | action: "reviewed", | ||
| 77 | kind: "guides", | ||
| 78 | subtype: "calculus", | ||
| 79 | score: "updated", | ||
| 80 | href: "/paths/limits-without-mysticism", | ||
| 81 | }, | ||
| 82 | { | ||
| 83 | title: "Practiced roots and powers", | ||
| 84 | meta: "27/30 correct / squares and cubes", | ||
| 85 | action: "practiced", | ||
| 86 | kind: "play", | ||
| 87 | subtype: "roots and powers", | ||
| 88 | score: "488", | ||
| 89 | href: "/play/mental-arithmetic?category=roots-powers&star=3", | ||
| 90 | }, | ||
| 91 | { | ||
| 92 | title: "Started Complex numbers as geometry", | ||
| 93 | meta: "Guide / complex analysis / roots and rotations", | ||
| 94 | action: "started", | ||
| 95 | kind: "guides", | ||
| 96 | subtype: "complex analysis", | ||
| 97 | score: "8 min", | ||
| 98 | href: "/paths/complex-numbers-as-geometry", | ||
| 99 | }, | ||
| 100 | { | ||
| 101 | title: "Solved a trigonometry warmup", | ||
| 102 | meta: "Problems / trigonometry / identities", | ||
| 103 | action: "solved", | ||
| 104 | kind: "problems", | ||
| 105 | subtype: "trigonometry", | ||
| 106 | score: "6", | ||
| 107 | href: "/problems", | ||
| 108 | }, | ||
| 109 | ]; | ||
| 110 | |||
| 111 | const attempts = [ | ||
| 112 | { expression: "874 - 392 = 482", result: "Correct", category: "subtraction" }, | ||
| 113 | { expression: "43 x 17 = 731", result: "Correct", category: "multiplication" }, | ||
| 114 | { expression: "sqrt(324) = 18", result: "Correct", category: "roots and powers" }, | ||
| 115 | { expression: "15% of 360 = 54", result: "Correct", category: "percents" }, | ||
| 116 | ]; | ||
| 117 | |||
| 118 | const tabLabels: Record<FeedTab, string> = { | ||
| 119 | activity: "Activity", | ||
| 120 | play: "Play", | ||
| 121 | problems: "Problems", | ||
| 122 | guides: "Guides", | ||
| 123 | }; | ||
| 124 | |||
| 125 | const tabFilters: Record<FeedTab, string[]> = { | ||
| 126 | activity: ["all", "published", "practiced", "completed", "solved", "reviewed", "started"], | ||
| 127 | play: ["all", "subtraction", "big multiply", "roots and powers", "percents"], | ||
| 128 | problems: ["all", "algebra", "trigonometry", "geometry", "calculus"], | ||
| 129 | guides: ["all", "algebra", "calculus", "complex analysis"], | ||
| 130 | }; | ||
| 131 | |||
| 1 | export default function ProfilePage() { | 132 | export default function ProfilePage() { |
| 133 | const [activeTab, setActiveTab] = useState<FeedTab>("activity"); | ||
| 134 | const [filter, setFilter] = useState("all"); | ||
| 135 | |||
| 136 | const shownItems = useMemo(() => { | ||
| 137 | return feedItems.filter((item) => { | ||
| 138 | const tabMatch = activeTab === "activity" || item.kind === activeTab; | ||
| 139 | const filterMatch = filter === "all" || item.subtype === filter || item.action === filter; | ||
| 140 | return tabMatch && filterMatch; | ||
| 141 | }); | ||
| 142 | }, [activeTab, filter]); | ||
| 143 | |||
| 144 | function setTab(tab: FeedTab) { | ||
| 145 | setActiveTab(tab); | ||
| 146 | setFilter("all"); | ||
| 147 | } | ||
| 148 | |||
| 149 | return ( | ||
| 150 | <div className="act-shell"> | ||
| 151 | <style>{profileStyles}</style> | ||
| 152 | <main className="act-main"> | ||
| 153 | <SurfaceNav variant="light" /> | ||
| 154 | |||
| 155 | <section className="act-profile"> | ||
| 156 | <div className="act-avatar"> | ||
| 157 | <img className="act-avatar-light" src="https://krisyotam.com/cdn/assets/logos/krisyotam-light.png" alt="Kris Yotam" /> | ||
| 158 | <img className="act-avatar-dark" src="https://krisyotam.com/cdn/assets/logos/krisyotam-dark.png" alt="Kris Yotam" /> | ||
| 159 | </div> | ||
| 160 | <div className="act-identity"> | ||
| 161 | <div className="act-handle">@krisyotam</div> | ||
| 162 | <h1>Kris Yotam</h1> | ||
| 163 | <p className="act-bio"> | ||
| 164 | Building SumTex as a place for fast arithmetic practice, long-form mathematical guides, and eventually serious problem solving. | ||
| 165 | </p> | ||
| 166 | <p>Maintaining since 2026</p> | ||
| 167 | </div> | ||
| 168 | </section> | ||
| 169 | |||
| 170 | <section className="act-badges" aria-label="Profile badges"> | ||
| 171 | {badges.map((badge) => ( | ||
| 172 | <div key={badge.label} className={`act-badge${badge.tone ? ` ${badge.tone}` : ""}`}> | ||
| 173 | <span>{badge.label}</span> | ||
| 174 | <strong>{badge.value}</strong> | ||
| 175 | </div> | ||
| 176 | ))} | ||
| 177 | </section> | ||
| 178 | |||
| 179 | <section className="act-stats" aria-label="Profile stats"> | ||
| 180 | {stats.map((stat) => ( | ||
| 181 | <Metric key={stat.label} label={stat.label} value={stat.value} tone={stat.tone} /> | ||
| 182 | ))} | ||
| 183 | </section> | ||
| 184 | |||
| 185 | <section className="act-feed"> | ||
| 186 | <div className="act-feed-tabs"> | ||
| 187 | <div className="act-tab-group"> | ||
| 188 | {(Object.keys(tabLabels) as FeedTab[]).map((tab) => ( | ||
| 189 | <button | ||
| 190 | key={tab} | ||
| 191 | className={`act-tab${activeTab === tab ? " active" : ""}`} | ||
| 192 | type="button" | ||
| 193 | onClick={() => setTab(tab)} | ||
| 194 | > | ||
| 195 | {tabLabels[tab]} | ||
| 196 | </button> | ||
| 197 | ))} | ||
| 198 | </div> | ||
| 199 | <div className="act-feed-actions"> | ||
| 200 | <label className="act-filter-label"> | ||
| 201 | <span>{activeTab === "activity" ? "Type" : "Subtype"}</span> | ||
| 202 | <select value={filter} onChange={(event) => setFilter(event.target.value)}> | ||
| 203 | {tabFilters[activeTab].map((option) => ( | ||
| 204 | <option key={option} value={option}> | ||
| 205 | {option} | ||
| 206 | </option> | ||
| 207 | ))} | ||
| 208 | </select> | ||
| 209 | </label> | ||
| 210 | <Link className="act-tab" href="/profile/settings">Manage account</Link> | ||
| 211 | <Link className="act-tab" href="/play/mental-arithmetic?category=subtraction&star=5">Play</Link> | ||
| 212 | </div> | ||
| 213 | </div> | ||
| 214 | |||
| 215 | <div className="act-feed-list"> | ||
| 216 | {shownItems.map((item) => ( | ||
| 217 | <FeedRow key={`${item.action}-${item.title}`} item={item} /> | ||
| 218 | ))} | ||
| 219 | {shownItems.length === 0 && ( | ||
| 220 | <div className="act-empty">No demo activity matches this filter.</div> | ||
| 221 | )} | ||
| 222 | </div> | ||
| 223 | </section> | ||
| 224 | |||
| 225 | <section className="act-feed act-attempts"> | ||
| 226 | <div className="act-feed-tabs"> | ||
| 227 | <div> | ||
| 228 | <button className="act-tab active" type="button">Recent attempts</button> | ||
| 229 | </div> | ||
| 230 | <Link className="act-tab" href="/play">Modes</Link> | ||
| 231 | </div> | ||
| 232 | <div className="act-feed-list"> | ||
| 233 | {attempts.map((attempt) => ( | ||
| 234 | <div key={attempt.expression} className="act-feed-item"> | ||
| 235 | <div className="act-feed-row"> | ||
| 236 | <div> | ||
| 237 | <span className="act-feed-title">{attempt.expression}</span> | ||
| 238 | <span className="act-feed-time">{attempt.category}</span> | ||
| 239 | </div> | ||
| 240 | <div className="act-feed-side"> | ||
| 241 | <span className="act-pill green">{attempt.result}</span> | ||
| 242 | </div> | ||
| 243 | </div> | ||
| 244 | </div> | ||
| 245 | ))} | ||
| 246 | </div> | ||
| 247 | </section> | ||
| 248 | </main> | ||
| 249 | </div> | ||
| 250 | ); | ||
| 251 | } | ||
| 252 | |||
| 253 | function FeedRow({ item }: { item: FeedItem }) { | ||
| 254 | const row = ( | ||
| 255 | <div className="act-feed-row"> | ||
| 256 | <div> | ||
| 257 | <div className="act-kind-line"> | ||
| 258 | <span className={`act-action act-action-${item.action}`}>{item.action}</span> | ||
| 259 | <span className="act-kind">{item.kind}</span> | ||
| 260 | <span className="act-kind">{item.subtype}</span> | ||
| 261 | </div> | ||
| 262 | <span className="act-feed-title">{item.title}</span> | ||
| 263 | <span className="act-feed-time">{item.meta}</span> | ||
| 264 | </div> | ||
| 265 | <div className="act-feed-side"> | ||
| 266 | {item.score && <span className="act-pill">{item.score}</span>} | ||
| 267 | </div> | ||
| 268 | </div> | ||
| 269 | ); | ||
| 270 | |||
| 271 | return ( | ||
| 272 | <div className="act-feed-item"> | ||
| 273 | {item.href ? <Link href={item.href}>{row}</Link> : row} | ||
| 274 | </div> | ||
| 275 | ); | ||
| 276 | } | ||
| 277 | |||
| 278 | function Metric({ label, value, tone }: { label: string; value: string; tone?: string }) { | ||
| 279 | const stat = stats.find((item) => item.label === label); | ||
| 280 | |||
| 2 | return ( | 281 | return ( |
| 3 | <div className="max-w-4xl mx-auto px-6 py-12"> | 282 | <div className="act-stat"> |
| 4 | <h1 | 283 | <div className={`act-stat-badge${tone === "green" ? " green" : ""}`}>{stat?.mark ?? label.slice(0, 1)}</div> |
| 5 | className="text-2xl font-semibold mb-4" | 284 | <div className={`act-stat-value${tone === "green" ? " green" : ""}`}>{value}</div> |
| 6 | style={{ color: "var(--text-primary)" }} | 285 | <div className="act-stat-label">{label}</div> |
| 7 | > | ||
| 8 | Profile | ||
| 9 | </h1> | ||
| 10 | <p style={{ color: "var(--text-secondary)" }}> | ||
| 11 | Edit your profile, bio, and links. | ||
| 12 | </p> | ||
| 13 | </div> | 286 | </div> |
| 14 | ); | 287 | ); |
| 15 | } | 288 | } |
| 289 | |||
| 290 | const profileStyles = ` | ||
| 291 | .act-shell { | ||
| 292 | --surface-bg: #ffffff; | ||
| 293 | --surface-card: #ffffff; | ||
| 294 | --surface-text: #333; | ||
| 295 | --surface-strong: #000; | ||
| 296 | --surface-muted: #666; | ||
| 297 | --surface-faint: #888; | ||
| 298 | --surface-line: #e0e0e0; | ||
| 299 | --surface-soft: #f4f4f4; | ||
| 300 | min-height: 100vh; | ||
| 301 | background: var(--surface-bg); | ||
| 302 | color: var(--surface-text); | ||
| 303 | } | ||
| 304 | |||
| 305 | .dark .act-shell { | ||
| 306 | --surface-bg: #0d0d0d; | ||
| 307 | --surface-card: #111; | ||
| 308 | --surface-text: #fff; | ||
| 309 | --surface-strong: #fff; | ||
| 310 | --surface-muted: #bdbdbd; | ||
| 311 | --surface-faint: #888; | ||
| 312 | --surface-line: #2a2a2a; | ||
| 313 | --surface-soft: #1c1c1c; | ||
| 314 | } | ||
| 315 | |||
| 316 | .act-shell a { color: inherit; text-decoration: none; } | ||
| 317 | .act-main { margin: 0 auto; max-width: 980px; padding: 28px 20px 72px; } | ||
| 318 | .act-profile { align-items: center; display: flex; gap: 24px; justify-content: flex-start; margin: 10px 0 42px; text-align: left; width: 100%; } | ||
| 319 | .act-avatar { align-items: center; background: var(--surface-card); border: 2px solid var(--surface-strong); border-radius: 50%; display: flex; flex-shrink: 0; height: 104px; justify-content: center; overflow: hidden; width: 104px; } | ||
| 320 | .dark .act-avatar { border-color: #fff; } | ||
| 321 | .act-avatar img { display: block; height: 100%; object-fit: cover; width: 100%; } | ||
| 322 | .act-avatar-dark { display: none !important; } | ||
| 323 | .dark .act-avatar-light { display: none !important; } | ||
| 324 | .dark .act-avatar-dark { display: block !important; } | ||
| 325 | .act-handle { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.12em; margin-bottom: 6px; text-transform: uppercase; } | ||
| 326 | .act-identity h1 { color: var(--surface-strong); font-size: 34px; font-weight: 600; letter-spacing: 0; line-height: 1.1; margin: 0 0 8px; } | ||
| 327 | .act-identity p { color: var(--surface-muted); font-size: 14px; line-height: 1.45; margin: 0; max-width: 520px; } | ||
| 328 | .act-identity .act-bio { margin-bottom: 8px; } | ||
| 329 | .act-badges { display: flex; flex-wrap: wrap; gap: 10px; margin: -22px 0 28px; } | ||
| 330 | .act-badge { align-items: center; background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 999px; display: inline-flex; gap: 9px; min-height: 34px; padding: 5px 12px; } | ||
| 331 | .act-badge span { color: var(--surface-faint); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; } | ||
| 332 | .act-badge strong { color: var(--surface-strong); font-family: var(--font-mono); font-size: 12px; font-weight: 600; text-transform: uppercase; } | ||
| 333 | .act-badge.strong { background: var(--surface-strong); border-color: var(--surface-strong); } | ||
| 334 | .act-badge.strong span, .act-badge.strong strong { color: var(--surface-bg); } | ||
| 335 | .act-badge.green { border-color: rgba(47, 111, 66, 0.35); } | ||
| 336 | .act-badge.green strong { color: #2f6f42; } | ||
| 337 | .act-stats { display: grid; gap: 16px; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0 0 28px; } | ||
| 338 | .act-stat { align-items: center; background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 12px; display: flex; flex-direction: column; padding: 15px 12px 13px; text-align: center; } | ||
| 339 | .act-stat-badge { align-items: center; background: var(--surface-soft); border-radius: 999px; color: var(--surface-muted); display: flex; font-family: var(--font-mono); font-size: 13px; height: 30px; justify-content: center; margin-bottom: 12px; width: 30px; } | ||
| 340 | .act-stat-badge.green { background: rgba(47, 111, 66, 0.12); color: #2f6f42; } | ||
| 341 | .act-stat-value { color: var(--surface-strong); font-family: var(--font-mono); font-size: 24px; font-weight: 600; line-height: 1.1; } | ||
| 342 | .act-stat-value.green { color: #2f6f42; } | ||
| 343 | .act-stat-label { color: var(--surface-faint); font-size: 12px; line-height: 1.2; margin-top: 5px; } | ||
| 344 | .act-feed { background: transparent; margin-top: 22px; } | ||
| 345 | .act-feed-tabs { align-items: center; border-bottom: 1px solid var(--surface-line); display: flex; gap: 18px; justify-content: space-between; padding: 10px; } | ||
| 346 | .act-tab-group, .act-feed-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 4px; } | ||
| 347 | .act-feed-actions { justify-content: flex-end; } | ||
| 348 | .act-tab { background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--surface-muted); cursor: pointer; display: inline-flex; font-size: 13px; line-height: 1; margin-bottom: -11px; padding: 8px 10px 18px; text-decoration: none; } | ||
| 349 | .act-tab.active { border-bottom-color: var(--surface-strong); color: var(--surface-strong); } | ||
| 350 | .act-filter-label { align-items: center; color: var(--surface-faint); display: inline-flex; font-family: var(--font-mono); font-size: 11px; gap: 8px; letter-spacing: 0.08em; margin-right: 4px; text-transform: uppercase; } | ||
| 351 | .act-filter-label select { appearance: none; background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); cursor: pointer; font-family: var(--font-mono); font-size: 12px; height: 32px; line-height: 1; padding: 0 28px 0 12px; text-transform: lowercase; } | ||
| 352 | .act-feed-list { display: flex; flex-direction: column; gap: 10px; padding-top: 18px; } | ||
| 353 | .act-feed-item { background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 12px; overflow: hidden; } | ||
| 354 | .act-feed-row { align-items: center; background: transparent; border: 0; color: var(--surface-text); display: grid; gap: 16px; grid-template-columns: 1fr auto; padding: 16px 18px; text-align: left; width: 100%; } | ||
| 355 | .act-kind-line { align-items: center; display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 8px; } | ||
| 356 | .act-action, .act-kind { border-radius: 999px; display: inline-flex; font-family: var(--font-mono); font-size: 11px; line-height: 1; padding: 4px 8px; text-transform: lowercase; } | ||
| 357 | .act-action { background: #101010; border: 1px solid #101010; color: #fff; } | ||
| 358 | .dark .act-action { background: #fff; border-color: #fff; color: #000; } | ||
| 359 | .act-action-published { background: #2f6f42; border-color: #2f6f42; color: #fff; } | ||
| 360 | .act-action-practiced, .act-action-completed, .act-action-solved { background: var(--surface-soft); border: 1px solid var(--surface-line); color: var(--surface-strong); } | ||
| 361 | .act-kind { background: transparent; border: 1px solid var(--surface-line); color: var(--surface-faint); } | ||
| 362 | .act-feed-title { color: var(--surface-strong); display: block; font-size: 15px; font-weight: 500; } | ||
| 363 | .act-feed-time, .act-pill { color: var(--surface-faint); font-size: 12px; } | ||
| 364 | .act-feed-side { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; } | ||
| 365 | .act-pill { background: var(--surface-soft); border: 1px solid var(--surface-line); border-radius: 999px; font-family: var(--font-mono); padding: 4px 9px; text-transform: lowercase; } | ||
| 366 | .act-pill.green { color: #2f6f42; } | ||
| 367 | .act-empty { border: 1px solid var(--surface-line); border-radius: 12px; color: var(--surface-muted); font-size: 14px; padding: 24px; text-align: center; } | ||
| 368 | @media (max-width: 900px) { | ||
| 369 | .act-profile { align-items: center; flex-direction: column; text-align: center; } | ||
| 370 | .act-badges { justify-content: center; margin-top: -20px; } | ||
| 371 | .act-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } | ||
| 372 | .act-feed-tabs { align-items: flex-start; flex-direction: column; gap: 8px; } | ||
| 373 | .act-feed-actions { justify-content: flex-start; } | ||
| 374 | .act-feed-row { grid-template-columns: 1fr; } | ||
| 375 | .act-feed-side { justify-content: flex-start; } | ||
| 376 | } | ||
| 377 | `; | ||
| @@ -1,15 +1,52 @@ | |||
| 1 | export default function SettingsPage() { | 1 | import { UserProfile } from "@clerk/nextjs"; |
| 2 | import { currentUser } from "@clerk/nextjs/server"; | ||
| 3 | import { SurfaceNav } from "@/components/navigation/surfaceNav"; | ||
| 4 | |||
| 5 | export default async function SettingsPage() { | ||
| 6 | const user = await currentUser(); | ||
| 7 | |||
| 2 | return ( | 8 | return ( |
| 3 | <div className="max-w-4xl mx-auto px-6 py-12"> | 9 | <main className="settings-shell"> |
| 4 | <h1 | 10 | <style>{settingsStyles}</style> |
| 5 | className="text-2xl font-semibold mb-4" | 11 | <div className="settings-frame"> |
| 6 | style={{ color: "var(--text-primary)" }} | 12 | <SurfaceNav variant="light" /> |
| 7 | > | 13 | <section className="settings-head"> |
| 8 | Settings | 14 | <p>SumTex Profile</p> |
| 9 | </h1> | 15 | <h1>Account management</h1> |
| 10 | <p style={{ color: "var(--text-secondary)" }}> | 16 | </section> |
| 11 | Manage your API keys, custom CSS, and account settings. | 17 | {user ? ( |
| 12 | </p> | 18 | <UserProfile |
| 13 | </div> | 19 | routing="path" |
| 20 | path="/profile/settings" | ||
| 21 | appearance={{ | ||
| 22 | elements: { | ||
| 23 | rootBox: "settings-clerk-root", | ||
| 24 | cardBox: "settings-clerk-card", | ||
| 25 | }, | ||
| 26 | }} | ||
| 27 | /> | ||
| 28 | ) : ( | ||
| 29 | <section className="settings-empty"> | ||
| 30 | <h2>Sign in to manage your account.</h2> | ||
| 31 | <a href="/sign-in">Sign in</a> | ||
| 32 | </section> | ||
| 33 | )} | ||
| 34 | </div> | ||
| 35 | </main> | ||
| 14 | ); | 36 | ); |
| 15 | } | 37 | } |
| 38 | |||
| 39 | const settingsStyles = ` | ||
| 40 | .settings-shell { min-height: 100vh; background: #f0f0f0; color: #333; padding: 28px 20px 72px; } | ||
| 41 | .dark .settings-shell { background: #0d0d0d; color: #fff; } | ||
| 42 | .settings-frame { margin: 0 auto; max-width: 980px; } | ||
| 43 | .settings-head { margin: 0 0 28px; } | ||
| 44 | .settings-head p { color: #777; font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; margin: 0 0 10px; text-transform: uppercase; } | ||
| 45 | .settings-head h1 { color: inherit; font-size: clamp(38px, 6vw, 72px); font-weight: 400; letter-spacing: 0; line-height: 1; margin: 0; } | ||
| 46 | .settings-empty { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 28px; } | ||
| 47 | .dark .settings-empty { background: #111; border-color: #2a2a2a; } | ||
| 48 | .settings-empty h2 { color: inherit; font-size: 22px; font-weight: 500; letter-spacing: 0; margin: 0 0 18px; } | ||
| 49 | .settings-empty a { border: 1px solid #d8d8d8; border-radius: 999px; color: inherit; display: inline-flex; font-family: var(--font-mono); font-size: 12px; height: 36px; align-items: center; padding: 0 14px; text-decoration: none; text-transform: uppercase; } | ||
| 50 | .settings-clerk-root { width: 100%; } | ||
| 51 | .settings-clerk-card { border-radius: 8px; box-shadow: none; width: 100%; } | ||
| 52 | `; | ||
| @@ -0,0 +1,78 @@ | |||
| 1 | import { NextResponse } from "next/server"; | ||
| 2 | import { auth } from "@clerk/nextjs/server"; | ||
| 3 | import { and, eq, sql } from "drizzle-orm"; | ||
| 4 | import { z } from "zod"; | ||
| 5 | import { getCurrentLocalUser } from "@/lib/auth/user"; | ||
| 6 | import { db } from "@/lib/db"; | ||
| 7 | import { arithmeticAttempts, arithmeticSessions } from "@/lib/db/schema"; | ||
| 8 | |||
| 9 | const payloadSchema = z.object({ | ||
| 10 | sessionId: z.string().uuid().nullable(), | ||
| 11 | problem: z.object({ | ||
| 12 | category: z.string(), | ||
| 13 | submode: z.string().optional(), | ||
| 14 | star: z.number().int().min(1).max(5), | ||
| 15 | expression: z.string(), | ||
| 16 | canonicalAnswer: z.string(), | ||
| 17 | answerFormat: z.string(), | ||
| 18 | acceptsFractions: z.boolean(), | ||
| 19 | acceptsDecimals: z.boolean(), | ||
| 20 | metadata: z.record(z.string(), z.unknown()), | ||
| 21 | }), | ||
| 22 | userAnswer: z.string(), | ||
| 23 | correct: z.boolean(), | ||
| 24 | elapsedMs: z.number().int().min(0), | ||
| 25 | scoreDelta: z.number().int().min(0), | ||
| 26 | }); | ||
| 27 | |||
| 28 | export async function POST(request: Request) { | ||
| 29 | const clerkSession = await auth(); | ||
| 30 | const payload = payloadSchema.parse(await request.json()); | ||
| 31 | |||
| 32 | if (!clerkSession.isAuthenticated || !payload.sessionId) { | ||
| 33 | return NextResponse.json({ persisted: false }); | ||
| 34 | } | ||
| 35 | |||
| 36 | const user = await getCurrentLocalUser(); | ||
| 37 | |||
| 38 | if (!user) { | ||
| 39 | return NextResponse.json({ persisted: false }); | ||
| 40 | } | ||
| 41 | |||
| 42 | const arithmeticSession = await db.query.arithmeticSessions.findFirst({ | ||
| 43 | where: and( | ||
| 44 | eq(arithmeticSessions.id, payload.sessionId), | ||
| 45 | eq(arithmeticSessions.userId, user.id) | ||
| 46 | ), | ||
| 47 | }); | ||
| 48 | |||
| 49 | if (!arithmeticSession) { | ||
| 50 | return NextResponse.json({ persisted: false }, { status: 404 }); | ||
| 51 | } | ||
| 52 | |||
| 53 | await db.insert(arithmeticAttempts).values({ | ||
| 54 | sessionId: payload.sessionId, | ||
| 55 | generatedPrompt: payload.problem, | ||
| 56 | displayExpression: payload.problem.expression, | ||
| 57 | canonicalAnswer: payload.problem.canonicalAnswer, | ||
| 58 | userAnswer: payload.userAnswer, | ||
| 59 | correct: payload.correct, | ||
| 60 | elapsedMs: payload.elapsedMs, | ||
| 61 | scoreDelta: payload.scoreDelta, | ||
| 62 | category: payload.problem.category, | ||
| 63 | submode: payload.problem.submode ?? null, | ||
| 64 | star: payload.problem.star, | ||
| 65 | metadata: payload.problem.metadata, | ||
| 66 | }); | ||
| 67 | |||
| 68 | await db | ||
| 69 | .update(arithmeticSessions) | ||
| 70 | .set({ | ||
| 71 | score: sql`${arithmeticSessions.score} + ${payload.scoreDelta}`, | ||
| 72 | totalAttempts: sql`${arithmeticSessions.totalAttempts} + 1`, | ||
| 73 | correctAttempts: sql`${arithmeticSessions.correctAttempts} + ${payload.correct ? 1 : 0}`, | ||
| 74 | }) | ||
| 75 | .where(eq(arithmeticSessions.id, payload.sessionId)); | ||
| 76 | |||
| 77 | return NextResponse.json({ persisted: true }); | ||
| 78 | } | ||
| @@ -0,0 +1,43 @@ | |||
| 1 | import { NextResponse } from "next/server"; | ||
| 2 | import { auth } from "@clerk/nextjs/server"; | ||
| 3 | import { and, eq } from "drizzle-orm"; | ||
| 4 | import { z } from "zod"; | ||
| 5 | import { getCurrentLocalUser } from "@/lib/auth/user"; | ||
| 6 | import { db } from "@/lib/db"; | ||
| 7 | import { arithmeticSessions } from "@/lib/db/schema"; | ||
| 8 | |||
| 9 | const payloadSchema = z.object({ | ||
| 10 | sessionId: z.string().uuid().nullable(), | ||
| 11 | score: z.number().int().min(0), | ||
| 12 | totalAttempts: z.number().int().min(0), | ||
| 13 | correctAttempts: z.number().int().min(0), | ||
| 14 | }); | ||
| 15 | |||
| 16 | export async function POST(request: Request) { | ||
| 17 | const session = await auth(); | ||
| 18 | const payload = payloadSchema.parse(await request.json()); | ||
| 19 | |||
| 20 | if (!session.isAuthenticated || !payload.sessionId) { | ||
| 21 | return NextResponse.json({ persisted: false }); | ||
| 22 | } | ||
| 23 | |||
| 24 | const user = await getCurrentLocalUser(); | ||
| 25 | |||
| 26 | if (!user) { | ||
| 27 | return NextResponse.json({ persisted: false }); | ||
| 28 | } | ||
| 29 | |||
| 30 | await db | ||
| 31 | .update(arithmeticSessions) | ||
| 32 | .set({ | ||
| 33 | score: payload.score, | ||
| 34 | totalAttempts: payload.totalAttempts, | ||
| 35 | correctAttempts: payload.correctAttempts, | ||
| 36 | endedAt: new Date(), | ||
| 37 | }) | ||
| 38 | .where( | ||
| 39 | and(eq(arithmeticSessions.id, payload.sessionId), eq(arithmeticSessions.userId, user.id)) | ||
| 40 | ); | ||
| 41 | |||
| 42 | return NextResponse.json({ persisted: true }); | ||
| 43 | } | ||
| @@ -0,0 +1,52 @@ | |||
| 1 | import { NextResponse } from "next/server"; | ||
| 2 | import { auth } from "@clerk/nextjs/server"; | ||
| 3 | import { z } from "zod"; | ||
| 4 | import { getCurrentLocalUser } from "@/lib/auth/user"; | ||
| 5 | import { db } from "@/lib/db"; | ||
| 6 | import { arithmeticSessions } from "@/lib/db/schema"; | ||
| 7 | |||
| 8 | const payloadSchema = z.object({ | ||
| 9 | config: z.object({ | ||
| 10 | mode: z.enum(["standard", "custom"]), | ||
| 11 | category: z.string(), | ||
| 12 | submode: z.string().optional(), | ||
| 13 | star: z.number().int().min(1).max(5), | ||
| 14 | runMode: z.enum(["untimed", "30s", "60s", "120s", "300s"]), | ||
| 15 | magnitude: z.string().optional(), | ||
| 16 | customConfig: z.unknown().optional(), | ||
| 17 | }), | ||
| 18 | seed: z.number().int(), | ||
| 19 | }); | ||
| 20 | |||
| 21 | export async function POST(request: Request) { | ||
| 22 | const clerkSession = await auth(); | ||
| 23 | |||
| 24 | if (!clerkSession.isAuthenticated) { | ||
| 25 | return NextResponse.json({ persisted: false, sessionId: null }); | ||
| 26 | } | ||
| 27 | |||
| 28 | const payload = payloadSchema.parse(await request.json()); | ||
| 29 | const user = await getCurrentLocalUser(); | ||
| 30 | |||
| 31 | if (!user) { | ||
| 32 | return NextResponse.json({ persisted: false, sessionId: null }); | ||
| 33 | } | ||
| 34 | |||
| 35 | const durationSeconds = | ||
| 36 | payload.config.runMode === "untimed" ? null : Number(payload.config.runMode.replace("s", "")); | ||
| 37 | const [createdSession] = await db | ||
| 38 | .insert(arithmeticSessions) | ||
| 39 | .values({ | ||
| 40 | userId: user.id, | ||
| 41 | mode: payload.config.mode, | ||
| 42 | category: payload.config.category, | ||
| 43 | submode: payload.config.submode ?? null, | ||
| 44 | star: payload.config.star, | ||
| 45 | customConfig: payload.config.customConfig, | ||
| 46 | seed: payload.seed, | ||
| 47 | durationSeconds, | ||
| 48 | }) | ||
| 49 | .returning({ id: arithmeticSessions.id }); | ||
| 50 | |||
| 51 | return NextResponse.json({ persisted: true, sessionId: createdSession?.id ?? null }); | ||
| 52 | } | ||
| @@ -9,8 +9,8 @@ · 8 unmodified lines | |||
| 9 | --text-secondary: #666666; | 9 | --text-secondary: #666666; |
| 10 | --text-muted: #888888; | 10 | --text-muted: #888888; |
| 11 | --border-color: #e0e0e0; | 11 | --border-color: #e0e0e0; |
| 12 | --link-color: rgba(10, 132, 255, 1); | 12 | --link-color: var(--text-primary); |
| 13 | --link-visited: #af52de; | 13 | --link-visited: var(--text-secondary); |
| 14 | --accent: #333333; | 14 | --accent: #333333; |
| 15 | --accent-muted: #666666; | 15 | --accent-muted: #666666; |
| 16 | 16 | ||
| @@ -52,8 +52,8 @@ · 35 unmodified lines | |||
| 52 | --text-secondary: hsl(0 0% 75%); | 52 | --text-secondary: hsl(0 0% 75%); |
| 53 | --text-muted: hsl(0 0% 50%); | 53 | --text-muted: hsl(0 0% 50%); |
| 54 | --border-color: hsl(0 0% 20%); | 54 | --border-color: hsl(0 0% 20%); |
| 55 | --link-color: #79a8ff; | 55 | --link-color: var(--text-primary); |
| 56 | --link-visited: #f78fe7; | 56 | --link-visited: var(--text-secondary); |
| 57 | --accent: hsl(0 0% 100%); | 57 | --accent: hsl(0 0% 100%); |
| 58 | --accent-muted: hsl(0 0% 75%); | 58 | --accent-muted: hsl(0 0% 75%); |
| 59 | 59 | ||
| @@ -140,10 +140,740 @@ body { · 80 unmodified lines | |||
| 140 | } | 140 | } |
| 141 | 141 | ||
| 142 | a { | 142 | a { |
| 143 | color: var(--link-color); | 143 | color: inherit; |
| 144 | text-decoration: none; | 144 | text-decoration: none; |
| 145 | } | 145 | } |
| 146 | 146 | ||
| 147 | a:hover { | 147 | a:hover { |
| 148 | text-decoration: underline; | 148 | text-decoration: none; |
| 149 | } | ||
| 150 | |||
| 151 | button, | ||
| 152 | input { | ||
| 153 | font: inherit; | ||
| 154 | } | ||
| 155 | |||
| 156 | .math-display { | ||
| 157 | display: block; | ||
| 158 | font-family: var(--font-mono); | ||
| 159 | overflow-x: auto; | ||
| 160 | white-space: nowrap; | ||
| 161 | } | ||
| 162 | |||
| 163 | .math-display-inline { | ||
| 164 | display: inline; | ||
| 165 | overflow: visible; | ||
| 166 | white-space: normal; | ||
| 167 | } | ||
| 168 | |||
| 169 | .math-display-block { | ||
| 170 | border: 1px solid var(--surface-line, var(--border-color)); | ||
| 171 | border-radius: 8px; | ||
| 172 | color: var(--surface-strong, var(--text-primary)); | ||
| 173 | margin: 22px 0; | ||
| 174 | padding: 16px 18px; | ||
| 175 | } | ||
| 176 | |||
| 177 | .math-display-render { | ||
| 178 | display: none; | ||
| 179 | } | ||
| 180 | |||
| 181 | .auth-shell { | ||
| 182 | align-items: center; | ||
| 183 | background: #080808; | ||
| 184 | display: flex; | ||
| 185 | justify-content: center; | ||
| 186 | min-height: 100vh; | ||
| 187 | padding: 28px; | ||
| 188 | } | ||
| 189 | |||
| 190 | .surface-nav { | ||
| 191 | align-items: center; | ||
| 192 | display: flex; | ||
| 193 | justify-content: space-between; | ||
| 194 | margin: 0 0 48px; | ||
| 195 | } | ||
| 196 | |||
| 197 | .surface-brand { | ||
| 198 | font-family: var(--font-mono); | ||
| 199 | font-size: 13px; | ||
| 200 | font-weight: 600; | ||
| 201 | letter-spacing: 0.16em; | ||
| 202 | text-decoration: none; | ||
| 203 | text-transform: uppercase; | ||
| 204 | } | ||
| 205 | |||
| 206 | .surface-nav-links { | ||
| 207 | align-items: center; | ||
| 208 | display: flex; | ||
| 209 | flex-wrap: wrap; | ||
| 210 | gap: 6px; | ||
| 211 | justify-content: flex-end; | ||
| 212 | } | ||
| 213 | |||
| 214 | .surface-nav-links a, | ||
| 215 | .surface-theme-toggle, | ||
| 216 | .surface-auth-button { | ||
| 217 | align-items: center; | ||
| 218 | background: transparent; | ||
| 219 | border-radius: 999px; | ||
| 220 | cursor: pointer; | ||
| 221 | display: inline-flex; | ||
| 222 | font-family: var(--font-mono); | ||
| 223 | font-size: 12px; | ||
| 224 | height: 36px; | ||
| 225 | justify-content: center; | ||
| 226 | line-height: 1; | ||
| 227 | padding: 0 14px; | ||
| 228 | text-decoration: none; | ||
| 229 | text-transform: uppercase; | ||
| 230 | } | ||
| 231 | |||
| 232 | .surface-theme-toggle { | ||
| 233 | padding: 0; | ||
| 234 | width: 36px; | ||
| 235 | } | ||
| 236 | |||
| 237 | .surface-user-avatar { | ||
| 238 | height: 36px; | ||
| 239 | width: 36px; | ||
| 240 | } | ||
| 241 | |||
| 242 | .surface-user-popover { | ||
| 243 | border-radius: 8px; | ||
| 244 | } | ||
| 245 | |||
| 246 | .surface-nav-dark .surface-brand { | ||
| 247 | color: var(--surface-strong, #f4f2ee); | ||
| 248 | } | ||
| 249 | |||
| 250 | .surface-nav-dark .surface-nav-links a, | ||
| 251 | .surface-nav-dark .surface-theme-toggle, | ||
| 252 | .surface-nav-dark .surface-auth-button { | ||
| 253 | border: 1px solid var(--surface-line, #dedede); | ||
| 254 | color: var(--surface-muted, #aaa); | ||
| 255 | } | ||
| 256 | |||
| 257 | .surface-nav-dark .surface-nav-links a:hover, | ||
| 258 | .surface-nav-dark .surface-theme-toggle:hover, | ||
| 259 | .surface-nav-dark .surface-auth-button:hover { | ||
| 260 | border-color: var(--surface-muted, #666); | ||
| 261 | color: var(--surface-strong, #f4f2ee); | ||
| 262 | } | ||
| 263 | |||
| 264 | .surface-nav-light .surface-brand { | ||
| 265 | color: var(--surface-strong, #333); | ||
| 266 | } | ||
| 267 | |||
| 268 | .dark .surface-nav-light .surface-brand { | ||
| 269 | color: var(--surface-strong, #fff); | ||
| 270 | } | ||
| 271 | |||
| 272 | .surface-nav-light .surface-nav-links a, | ||
| 273 | .surface-nav-light .surface-theme-toggle, | ||
| 274 | .surface-nav-light .surface-auth-button { | ||
| 275 | border: 1px solid var(--surface-line, #dedede); | ||
| 276 | color: var(--surface-muted, #555); | ||
| 277 | } | ||
| 278 | |||
| 279 | .surface-nav-light .surface-nav-links a:hover, | ||
| 280 | .surface-nav-light .surface-theme-toggle:hover, | ||
| 281 | .surface-nav-light .surface-auth-button:hover { | ||
| 282 | border-color: var(--surface-muted, #777); | ||
| 283 | color: var(--surface-strong, #333); | ||
| 284 | } | ||
| 285 | |||
| 286 | .dark .surface-nav-light .surface-nav-links a, | ||
| 287 | .dark .surface-nav-light .surface-theme-toggle, | ||
| 288 | .dark .surface-nav-light .surface-auth-button { | ||
| 289 | border-color: var(--surface-line, #2a2a2a); | ||
| 290 | color: var(--surface-muted, #aaa); | ||
| 291 | } | ||
| 292 | |||
| 293 | .dark .surface-nav-light .surface-nav-links a:hover, | ||
| 294 | .dark .surface-nav-light .surface-theme-toggle:hover, | ||
| 295 | .dark .surface-nav-light .surface-auth-button:hover { | ||
| 296 | border-color: var(--surface-muted, #666); | ||
| 297 | color: var(--surface-strong, #fff); | ||
| 298 | } | ||
| 299 | |||
| 300 | @media (max-width: 900px) { | ||
| 301 | .surface-nav { | ||
| 302 | align-items: flex-start; | ||
| 303 | flex-direction: column; | ||
| 304 | gap: 18px; | ||
| 305 | margin-bottom: 38px; | ||
| 306 | } | ||
| 307 | |||
| 308 | .surface-nav-links { | ||
| 309 | justify-content: flex-start; | ||
| 310 | } | ||
| 311 | } | ||
| 312 | |||
| 313 | .sumtex-dark-shell { | ||
| 314 | min-height: calc(100vh - var(--topbar-height)); | ||
| 315 | background: #080808; | ||
| 316 | color: #f4f2ee; | ||
| 317 | } | ||
| 318 | |||
| 319 | .ma-shell { | ||
| 320 | min-height: calc(100vh - var(--topbar-height)); | ||
| 321 | background: #080808; | ||
| 322 | color: #f4f2ee; | ||
| 323 | padding: 34px 5vw 56px; | ||
| 324 | } | ||
| 325 | |||
| 326 | .ma-frame { | ||
| 327 | margin: 0 auto; | ||
| 328 | max-width: 1440px; | ||
| 329 | } | ||
| 330 | |||
| 331 | .ma-hero { | ||
| 332 | display: flex; | ||
| 333 | align-items: flex-start; | ||
| 334 | justify-content: space-between; | ||
| 335 | gap: 32px; | ||
| 336 | margin: 0 0 46px; | ||
| 337 | } | ||
| 338 | |||
| 339 | .ma-kicker { | ||
| 340 | color: #777; | ||
| 341 | font-family: var(--font-mono); | ||
| 342 | font-size: 13px; | ||
| 343 | letter-spacing: 0.18em; | ||
| 344 | line-height: 1; | ||
| 345 | margin: 0 0 22px; | ||
| 346 | text-transform: uppercase; | ||
| 347 | } | ||
| 348 | |||
| 349 | .ma-title { | ||
| 350 | color: #f5f3ef; | ||
| 351 | font-size: clamp(44px, 6vw, 92px); | ||
| 352 | font-weight: 400; | ||
| 353 | letter-spacing: 0; | ||
| 354 | line-height: 0.95; | ||
| 355 | margin: 0; | ||
| 356 | } | ||
| 357 | |||
| 358 | .ma-subtitle { | ||
| 359 | color: #8d8d8d; | ||
| 360 | font-size: 18px; | ||
| 361 | line-height: 1.5; | ||
| 362 | margin: 22px 0 0; | ||
| 363 | max-width: 620px; | ||
| 364 | } | ||
| 365 | |||
| 366 | .ma-primary-action { | ||
| 367 | align-items: center; | ||
| 368 | background: transparent; | ||
| 369 | border: 1px solid #3a3a3a; | ||
| 370 | border-radius: 999px; | ||
| 371 | color: #f1eee8; | ||
| 372 | cursor: pointer; | ||
| 373 | display: inline-flex; | ||
| 374 | font-family: var(--font-mono); | ||
| 375 | font-size: 13px; | ||
| 376 | font-weight: 600; | ||
| 377 | justify-content: center; | ||
| 378 | letter-spacing: 0.14em; | ||
| 379 | min-height: 54px; | ||
| 380 | padding: 0 28px; | ||
| 381 | text-transform: uppercase; | ||
| 382 | transition: border-color 0.2s ease, background 0.2s ease; | ||
| 383 | white-space: nowrap; | ||
| 384 | } | ||
| 385 | |||
| 386 | .ma-primary-action:hover { | ||
| 387 | background: #151515; | ||
| 388 | border-color: #686868; | ||
| 389 | } | ||
| 390 | |||
| 391 | .ma-layout { | ||
| 392 | align-items: start; | ||
| 393 | display: grid; | ||
| 394 | gap: 44px; | ||
| 395 | grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); | ||
| 396 | } | ||
| 397 | |||
| 398 | .ma-rail { | ||
| 399 | border-left: 1px solid #242424; | ||
| 400 | display: flex; | ||
| 401 | flex-direction: column; | ||
| 402 | gap: 32px; | ||
| 403 | padding-left: 26px; | ||
| 404 | } | ||
| 405 | |||
| 406 | .ma-group { | ||
| 407 | display: grid; | ||
| 408 | gap: 12px; | ||
| 409 | } | ||
| 410 | |||
| 411 | .ma-label { | ||
| 412 | color: #777; | ||
| 413 | font-family: var(--font-mono); | ||
| 414 | font-size: 12px; | ||
| 415 | letter-spacing: 0.14em; | ||
| 416 | line-height: 1; | ||
| 417 | text-transform: uppercase; | ||
| 418 | } | ||
| 419 | |||
| 420 | .ma-category-list { | ||
| 421 | display: grid; | ||
| 422 | gap: 1px; | ||
| 423 | } | ||
| 424 | |||
| 425 | .ma-line-option, | ||
| 426 | .ma-toggle-row, | ||
| 427 | .ma-number-row { | ||
| 428 | background: transparent; | ||
| 429 | border: 0; | ||
| 430 | border-bottom: 1px solid #1d1d1d; | ||
| 431 | color: #d8d5ce; | ||
| 432 | cursor: pointer; | ||
| 433 | display: flex; | ||
| 434 | gap: 16px; | ||
| 435 | justify-content: space-between; | ||
| 436 | min-height: 46px; | ||
| 437 | padding: 12px 0; | ||
| 438 | text-align: left; | ||
| 439 | } | ||
| 440 | |||
| 441 | .ma-line-option:hover, | ||
| 442 | .ma-line-option.active { | ||
| 443 | color: #fff; | ||
| 444 | } | ||
| 445 | |||
| 446 | .ma-line-option.active .ma-option-name::before { | ||
| 447 | background: #f4f2ee; | ||
| 448 | } | ||
| 449 | |||
| 450 | .ma-option-name { | ||
| 451 | align-items: center; | ||
| 452 | display: inline-flex; | ||
| 453 | font-size: 15px; | ||
| 454 | gap: 10px; | ||
| 455 | } | ||
| 456 | |||
| 457 | .ma-option-name::before { | ||
| 458 | background: #363636; | ||
| 459 | border-radius: 999px; | ||
| 460 | content: ""; | ||
| 461 | display: inline-block; | ||
| 462 | height: 7px; | ||
| 463 | width: 7px; | ||
| 464 | } | ||
| 465 | |||
| 466 | .ma-option-meta { | ||
| 467 | color: #6f6f6f; | ||
| 468 | font-size: 12px; | ||
| 469 | max-width: 130px; | ||
| 470 | text-align: right; | ||
| 471 | } | ||
| 472 | |||
| 473 | .ma-pill-row { | ||
| 474 | display: flex; | ||
| 475 | flex-wrap: wrap; | ||
| 476 | gap: 8px; | ||
| 477 | } | ||
| 478 | |||
| 479 | .ma-pill { | ||
| 480 | background: transparent; | ||
| 481 | border: 1px solid #343434; | ||
| 482 | border-radius: 999px; | ||
| 483 | color: #b8b4ad; | ||
| 484 | cursor: pointer; | ||
| 485 | font-family: var(--font-mono); | ||
| 486 | font-size: 12px; | ||
| 487 | min-height: 36px; | ||
| 488 | padding: 0 14px; | ||
| 489 | text-transform: uppercase; | ||
| 490 | } | ||
| 491 | |||
| 492 | .ma-pill.active { | ||
| 493 | background: #f1eee8; | ||
| 494 | border-color: #f1eee8; | ||
| 495 | color: #080808; | ||
| 496 | } | ||
| 497 | |||
| 498 | .ma-stage { | ||
| 499 | border-left: 1px solid #242424; | ||
| 500 | min-height: 650px; | ||
| 501 | padding-left: clamp(28px, 5vw, 76px); | ||
| 502 | } | ||
| 503 | |||
| 504 | .ma-metrics { | ||
| 505 | display: grid; | ||
| 506 | gap: 1px; | ||
| 507 | grid-template-columns: repeat(3, minmax(0, 1fr)); | ||
| 508 | margin: 0 0 42px; | ||
| 509 | } | ||
| 510 | |||
| 511 | .ma-metric { | ||
| 512 | border-top: 1px solid #242424; | ||
| 513 | padding: 18px 22px 0 0; | ||
| 514 | } | ||
| 515 | |||
| 516 | .ma-metric-label { | ||
| 517 | color: #777; | ||
| 518 | font-family: var(--font-mono); | ||
| 519 | font-size: 11px; | ||
| 520 | letter-spacing: 0.14em; | ||
| 521 | text-transform: uppercase; | ||
| 522 | } | ||
| 523 | |||
| 524 | .ma-metric-value { | ||
| 525 | color: #f1eee8; | ||
| 526 | font-family: var(--font-mono); | ||
| 527 | font-size: 30px; | ||
| 528 | line-height: 1.2; | ||
| 529 | margin-top: 8px; | ||
| 530 | } | ||
| 531 | |||
| 532 | .ma-problem-wrap { | ||
| 533 | margin: 0 auto; | ||
| 534 | max-width: 760px; | ||
| 535 | text-align: center; | ||
| 536 | } | ||
| 537 | |||
| 538 | .ma-expression { | ||
| 539 | color: #f7f4ef; | ||
| 540 | font-family: var(--font-mono); | ||
| 541 | font-size: clamp(46px, 7vw, 96px); | ||
| 542 | font-weight: 500; | ||
| 543 | letter-spacing: 0; | ||
| 544 | line-height: 1.05; | ||
| 545 | margin: 50px 0 34px; | ||
| 546 | min-height: 110px; | ||
| 547 | } | ||
| 548 | |||
| 549 | .ma-expression.correct { | ||
| 550 | color: #7fb68d; | ||
| 551 | } | ||
| 552 | |||
| 553 | .ma-expression.wrong { | ||
| 554 | color: #c98462; | ||
| 555 | } | ||
| 556 | |||
| 557 | .ma-answer { | ||
| 558 | background: #111; | ||
| 559 | border: 1px solid #2f2f2f; | ||
| 560 | border-radius: 999px; | ||
| 561 | color: #f4f2ee; | ||
| 562 | font-family: var(--font-mono); | ||
| 563 | font-size: 28px; | ||
| 564 | height: 66px; | ||
| 565 | outline: none; | ||
| 566 | padding: 0 26px; | ||
| 567 | text-align: center; | ||
| 568 | width: min(100%, 560px); | ||
| 569 | } | ||
| 570 | |||
| 571 | .ma-answer:focus { | ||
| 572 | border-color: #777; | ||
| 573 | } | ||
| 574 | |||
| 575 | .ma-keypad { | ||
| 576 | display: grid; | ||
| 577 | gap: 10px; | ||
| 578 | grid-template-columns: repeat(4, minmax(0, 1fr)); | ||
| 579 | margin: 24px auto 0; | ||
| 580 | max-width: 560px; | ||
| 581 | } | ||
| 582 | |||
| 583 | .ma-key { | ||
| 584 | background: #101010; | ||
| 585 | border: 1px solid #292929; | ||
| 586 | border-radius: 999px; | ||
| 587 | color: #d8d5ce; | ||
| 588 | cursor: pointer; | ||
| 589 | font-family: var(--font-mono); | ||
| 590 | font-size: 15px; | ||
| 591 | height: 52px; | ||
| 592 | } | ||
| 593 | |||
| 594 | .ma-key:hover { | ||
| 595 | background: #191919; | ||
| 596 | border-color: #555; | ||
| 597 | } | ||
| 598 | |||
| 599 | .ma-empty { | ||
| 600 | align-items: center; | ||
| 601 | display: flex; | ||
| 602 | flex-direction: column; | ||
| 603 | justify-content: center; | ||
| 604 | min-height: 450px; | ||
| 605 | text-align: center; | ||
| 606 | } | ||
| 607 | |||
| 608 | .ma-empty h2 { | ||
| 609 | color: #f4f2ee; | ||
| 610 | font-size: clamp(42px, 6vw, 82px); | ||
| 611 | font-weight: 400; | ||
| 612 | letter-spacing: 0; | ||
| 613 | line-height: 1; | ||
| 614 | margin: 0; | ||
| 615 | } | ||
| 616 | |||
| 617 | .ma-empty p { | ||
| 618 | color: #8d8d8d; | ||
| 619 | font-size: 17px; | ||
| 620 | line-height: 1.5; | ||
| 621 | margin: 20px 0 0; | ||
| 622 | max-width: 520px; | ||
| 623 | } | ||
| 624 | |||
| 625 | .ma-recent { | ||
| 626 | border-top: 1px solid #242424; | ||
| 627 | margin-top: 52px; | ||
| 628 | padding-top: 22px; | ||
| 629 | } | ||
| 630 | |||
| 631 | .ma-recent h3 { | ||
| 632 | color: #777; | ||
| 633 | font-family: var(--font-mono); | ||
| 634 | font-size: 12px; | ||
| 635 | letter-spacing: 0.14em; | ||
| 636 | margin: 0 0 14px; | ||
| 637 | text-transform: uppercase; | ||
| 638 | } | ||
| 639 | |||
| 640 | .ma-recent-row { | ||
| 641 | border-bottom: 1px solid #1c1c1c; | ||
| 642 | color: #a8a39a; | ||
| 643 | display: grid; | ||
| 644 | font-family: var(--font-mono); | ||
| 645 | font-size: 13px; | ||
| 646 | gap: 16px; | ||
| 647 | grid-template-columns: 1fr auto; | ||
| 648 | padding: 12px 0; | ||
| 649 | } | ||
| 650 | |||
| 651 | .ma-number-row { | ||
| 652 | align-items: center; | ||
| 653 | color: #d8d5ce; | ||
| 654 | cursor: default; | ||
| 655 | } | ||
| 656 | |||
| 657 | .ma-number-row input { | ||
| 658 | background: #111; | ||
| 659 | border: 1px solid #333; | ||
| 660 | border-radius: 999px; | ||
| 661 | color: #f4f2ee; | ||
| 662 | height: 34px; | ||
| 663 | padding: 0 12px; | ||
| 664 | text-align: center; | ||
| 665 | width: 78px; | ||
| 666 | } | ||
| 667 | |||
| 668 | .ma-toggle-row { | ||
| 669 | align-items: center; | ||
| 670 | color: #d8d5ce; | ||
| 671 | cursor: default; | ||
| 672 | } | ||
| 673 | |||
| 674 | .ma-toggle-row input { | ||
| 675 | accent-color: #f1eee8; | ||
| 676 | } | ||
| 677 | |||
| 678 | .act-shell { | ||
| 679 | min-height: calc(100vh - var(--topbar-height)); | ||
| 680 | background: var(--bg-canvas); | ||
| 681 | color: var(--text-primary); | ||
| 682 | } | ||
| 683 | |||
| 684 | .act-main { | ||
| 685 | margin: 0 auto; | ||
| 686 | max-width: 980px; | ||
| 687 | padding: 44px 20px 72px; | ||
| 688 | } | ||
| 689 | |||
| 690 | .act-profile { | ||
| 691 | align-items: center; | ||
| 692 | display: flex; | ||
| 693 | gap: 24px; | ||
| 694 | justify-content: center; | ||
| 695 | margin: 10px auto 34px; | ||
| 696 | text-align: left; | ||
| 697 | } | ||
| 698 | |||
| 699 | .act-avatar { | ||
| 700 | align-items: center; | ||
| 701 | background: var(--bg-primary); | ||
| 702 | border: 1px solid var(--border-color); | ||
| 703 | border-radius: 50%; | ||
| 704 | display: flex; | ||
| 705 | font-size: 42px; | ||
| 706 | font-weight: 600; | ||
| 707 | height: 104px; | ||
| 708 | justify-content: center; | ||
| 709 | overflow: hidden; | ||
| 710 | width: 104px; | ||
| 711 | } | ||
| 712 | |||
| 713 | .act-identity h1 { | ||
| 714 | color: var(--text-primary); | ||
| 715 | font-size: 32px; | ||
| 716 | font-weight: 600; | ||
| 717 | letter-spacing: 0; | ||
| 718 | line-height: 1.1; | ||
| 719 | margin: 0 0 8px; | ||
| 720 | } | ||
| 721 | |||
| 722 | .act-identity p { | ||
| 723 | color: var(--text-secondary); | ||
| 724 | font-size: 14px; | ||
| 725 | line-height: 1.45; | ||
| 726 | margin: 0; | ||
| 727 | } | ||
| 728 | |||
| 729 | .act-stats { | ||
| 730 | display: grid; | ||
| 731 | gap: 12px; | ||
| 732 | grid-template-columns: repeat(5, minmax(0, 1fr)); | ||
| 733 | margin: 0 0 28px; | ||
| 734 | } | ||
| 735 | |||
| 736 | .act-stat { | ||
| 737 | background: var(--bg-primary); | ||
| 738 | border: 1px solid var(--border-color); | ||
| 739 | border-radius: 8px; | ||
| 740 | padding: 16px; | ||
| 741 | } | ||
| 742 | |||
| 743 | .act-stat-badge { | ||
| 744 | align-items: center; | ||
| 745 | background: var(--bg-tertiary); | ||
| 746 | border-radius: 999px; | ||
| 747 | color: var(--text-secondary); | ||
| 748 | display: flex; | ||
| 749 | height: 30px; | ||
| 750 | justify-content: center; | ||
| 751 | margin-bottom: 12px; | ||
| 752 | width: 30px; | ||
| 753 | } | ||
| 754 | |||
| 755 | .act-stat-value { | ||
| 756 | color: var(--text-primary); | ||
| 757 | font-family: var(--font-mono); | ||
| 758 | font-size: 24px; | ||
| 759 | font-weight: 600; | ||
| 760 | line-height: 1.1; | ||
| 761 | } | ||
| 762 | |||
| 763 | .act-stat-label { | ||
| 764 | color: var(--text-muted); | ||
| 765 | font-size: 12px; | ||
| 766 | line-height: 1.2; | ||
| 767 | margin-top: 5px; | ||
| 768 | } | ||
| 769 | |||
| 770 | .act-feed { | ||
| 771 | background: var(--bg-primary); | ||
| 772 | border: 1px solid var(--border-color); | ||
| 773 | border-radius: 8px; | ||
| 774 | overflow: hidden; | ||
| 775 | } | ||
| 776 | |||
| 777 | .act-feed-tabs { | ||
| 778 | align-items: center; | ||
| 779 | border-bottom: 1px solid var(--border-color); | ||
| 780 | display: flex; | ||
| 781 | justify-content: space-between; | ||
| 782 | padding: 10px; | ||
| 783 | } | ||
| 784 | |||
| 785 | .act-tab { | ||
| 786 | background: transparent; | ||
| 787 | border: 0; | ||
| 788 | border-radius: 6px; | ||
| 789 | color: var(--text-secondary); | ||
| 790 | cursor: pointer; | ||
| 791 | font-size: 13px; | ||
| 792 | padding: 8px 12px; | ||
| 793 | } | ||
| 794 | |||
| 795 | .act-tab.active { | ||
| 796 | background: var(--bg-tertiary); | ||
| 797 | color: var(--text-primary); | ||
| 798 | } | ||
| 799 | |||
| 800 | .act-feed-list { | ||
| 801 | display: grid; | ||
| 802 | } | ||
| 803 | |||
| 804 | .act-feed-row { | ||
| 805 | align-items: center; | ||
| 806 | background: transparent; | ||
| 807 | border: 0; | ||
| 808 | border-bottom: 1px solid var(--border-color); | ||
| 809 | color: var(--text-primary); | ||
| 810 | display: grid; | ||
| 811 | gap: 16px; | ||
| 812 | grid-template-columns: 1fr auto; | ||
| 813 | padding: 16px 18px; | ||
| 814 | text-align: left; | ||
| 815 | width: 100%; | ||
| 816 | } | ||
| 817 | |||
| 818 | .act-feed-title { | ||
| 819 | display: block; | ||
| 820 | font-size: 15px; | ||
| 821 | font-weight: 500; | ||
| 822 | } | ||
| 823 | |||
| 824 | .act-feed-time, | ||
| 825 | .act-pill { | ||
| 826 | color: var(--text-muted); | ||
| 827 | font-size: 12px; | ||
| 828 | } | ||
| 829 | |||
| 830 | .act-feed-side { | ||
| 831 | align-items: center; | ||
| 832 | display: flex; | ||
| 833 | gap: 12px; | ||
| 834 | } | ||
| 835 | |||
| 836 | .act-feed-detail { | ||
| 837 | border-bottom: 1px solid var(--border-color); | ||
| 838 | color: var(--text-secondary); | ||
| 839 | font-size: 14px; | ||
| 840 | line-height: 1.55; | ||
| 841 | padding: 0 18px 18px; | ||
| 842 | } | ||
| 843 | |||
| 844 | .act-feed-empty { | ||
| 845 | color: var(--text-secondary); | ||
| 846 | padding: 24px 18px; | ||
| 847 | } | ||
| 848 | |||
| 849 | @media (max-width: 900px) { | ||
| 850 | .ma-shell { | ||
| 851 | padding: 28px 20px 48px; | ||
| 852 | } | ||
| 853 | |||
| 854 | .ma-hero { | ||
| 855 | align-items: stretch; | ||
| 856 | flex-direction: column; | ||
| 857 | margin-bottom: 34px; | ||
| 858 | } | ||
| 859 | |||
| 860 | .ma-layout { | ||
| 861 | grid-template-columns: 1fr; | ||
| 862 | } | ||
| 863 | |||
| 864 | .ma-rail, | ||
| 865 | .ma-stage { | ||
| 866 | border-left: 0; | ||
| 867 | padding-left: 0; | ||
| 868 | } | ||
| 869 | |||
| 870 | .act-profile { | ||
| 871 | align-items: center; | ||
| 872 | flex-direction: column; | ||
| 873 | text-align: center; | ||
| 874 | } | ||
| 875 | |||
| 876 | .act-stats { | ||
| 877 | grid-template-columns: repeat(2, minmax(0, 1fr)); | ||
| 878 | } | ||
| 149 | } | 879 | } |
| @@ -1,4 +1,5 @@ | |||
| 1 | import type { Metadata } from "next"; | 1 | import type { Metadata } from "next"; |
| 2 | import { ClerkProvider } from "@clerk/nextjs"; | ||
| 2 | import { GeistSans } from "geist/font/sans"; | 3 | import { GeistSans } from "geist/font/sans"; |
| 3 | import { GeistMono } from "geist/font/mono"; | 4 | import { GeistMono } from "geist/font/mono"; |
| 4 | import { ThemeProvider } from "@/lib/theme"; | 5 | import { ThemeProvider } from "@/lib/theme"; |
| @@ -19,9 +20,11 @@ export default function RootLayout({ · 14 unmodified lines | |||
| 19 | return ( | 20 | return ( |
| 20 | <html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}> | 21 | <html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}> |
| 21 | <body> | 22 | <body> |
| 22 | <ThemeProvider> | 23 | <ClerkProvider dynamic> |
| 23 | <MathJaxProvider>{children}</MathJaxProvider> | 24 | <ThemeProvider> |
| 24 | </ThemeProvider> | 25 | <MathJaxProvider>{children}</MathJaxProvider> |
| 26 | </ThemeProvider> | ||
| 27 | </ClerkProvider> | ||
| 25 | </body> | 28 | </body> |
| 26 | </html> | 29 | </html> |
| 27 | ); | 30 | ); |
| @@ -1,105 +1,64 @@ | |||
| 1 | import { Header } from "@/components/navigation/header"; | 1 | export default function HomePage() { |
| 2 | |||
| 3 | export default function LandingPage() { | ||
| 4 | return ( | 2 | return ( |
| 5 | <div | 3 | <main className="relative min-h-screen overflow-hidden bg-[#111111] text-[#f2f0ea]"> |
| 6 | className="min-h-screen flex flex-col" | 4 | <div className="absolute right-8 top-5 flex items-center gap-3 text-xs text-[#8d8d8d]"> |
| 7 | style={{ background: "var(--bg-canvas)" }} | 5 | <a href="/play/mental-arithmetic" className="rounded-full border border-[#303030] px-4 py-2 text-[#d8d5ce] no-underline"> |
| 8 | > | 6 | Mental Arithmetic |
| 9 | <Header /> | 7 | </a> |
| 10 | <main className="flex-1 flex flex-col items-center justify-center px-6"> | 8 | <a href="/profile" className="flex h-8 w-8 items-center justify-center rounded-full bg-[#202020] text-[#d8d5ce] no-underline"> |
| 11 | <div className="max-w-2xl text-center"> | 9 | S |
| 12 | <h1 | 10 | </a> |
| 13 | className="text-4xl font-semibold tracking-tight mb-4" | 11 | </div> |
| 14 | style={{ color: "var(--text-primary)" }} | 12 | |
| 15 | > | 13 | <div className="pointer-events-none absolute inset-0"> |
| 16 | SumTeX | 14 | <span className="absolute left-[6%] top-[16%] text-[#777]">+</span> |
| 17 | </h1> | 15 | <span className="absolute left-[25%] top-[38%] text-[#777]">.</span> |
| 18 | <p | 16 | <span className="absolute right-[18%] top-[28%] text-[#777]">.</span> |
| 19 | className="text-lg mb-8 leading-relaxed" | 17 | <span className="absolute bottom-[28%] left-[58%] text-[#777]">+</span> |
| 20 | style={{ color: "var(--text-secondary)" }} | 18 | <span className="absolute bottom-[18%] right-[7%] text-[#777]">.</span> |
| 19 | </div> | ||
| 20 | |||
| 21 | <section className="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center px-5 pb-24"> | ||
| 22 | <h1 className="mb-7 font-serif text-5xl font-semibold tracking-normal sm:text-6xl"> | ||
| 23 | SumTex <span className="align-baseline text-xl italic text-[#7a7a7a]">v1</span> | ||
| 24 | </h1> | ||
| 25 | <form action="/problems" className="flex h-14 w-full items-center rounded-full border border-[#303030] bg-[#1f1f1f] px-4 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]"> | ||
| 26 | <span className="mr-3 text-[#8a8a8a]">Search</span> | ||
| 27 | <input | ||
| 28 | name="q" | ||
| 29 | placeholder="Mental arithmetic, calculus, algebra" | ||
| 30 | className="min-w-0 flex-1 bg-transparent text-sm text-[#dedbd2] outline-none placeholder:text-[#696969]" | ||
| 31 | /> | ||
| 32 | <button | ||
| 33 | type="submit" | ||
| 34 | className="ml-3 flex h-10 w-10 items-center justify-center rounded-full bg-[#747474] text-lg leading-none text-[#111111]" | ||
| 35 | aria-label="Search" | ||
| 21 | > | 36 | > |
| 22 | Mathematics is learned through textbooks and repetition, not videos. | 37 | Go |
| 23 | Real problems from Rudin, Spivak, Artin, Munkres, and more. Solve | 38 | </button> |
| 24 | them. Track your progress. Get better. | 39 | </form> |
| 25 | </p> | 40 | </section> |
| 26 | <div className="flex items-center justify-center gap-4"> | ||
| 27 | <a | ||
| 28 | href="/play" | ||
| 29 | className="px-5 py-2.5 text-sm font-medium rounded no-underline transition-colors" | ||
| 30 | style={{ | ||
| 31 | background: "var(--text-primary)", | ||
| 32 | color: "var(--bg-primary)", | ||
| 33 | }} | ||
| 34 | > | ||
| 35 | Start solving | ||
| 36 | </a> | ||
| 37 | <a | ||
| 38 | href="/problems" | ||
| 39 | className="px-5 py-2.5 text-sm font-medium rounded no-underline transition-colors" | ||
| 40 | style={{ | ||
| 41 | background: "var(--bg-primary)", | ||
| 42 | color: "var(--text-primary)", | ||
| 43 | border: "1px solid var(--border-color)", | ||
| 44 | }} | ||
| 45 | > | ||
| 46 | Browse problems | ||
| 47 | </a> | ||
| 48 | </div> | ||
| 49 | </div> | ||
| 50 | 41 | ||
| 51 | <div | 42 | <footer className="absolute inset-x-0 bottom-6 grid grid-cols-3 items-end px-10 text-xs text-[#676767]"> |
| 52 | className="mt-16 grid grid-cols-3 gap-8 max-w-3xl w-full" | 43 | <div /> |
| 53 | style={{ color: "var(--text-secondary)" }} | 44 | <div className="text-center"> |
| 54 | > | 45 | <div>Generated drills</div> |
| 55 | <div className="text-center"> | 46 | <div className="mt-1 font-mono text-base text-[#aaa]">11 categories</div> |
| 56 | <div | 47 | </div> |
| 57 | className="text-2xl font-semibold mb-1" | 48 | <div className="justify-self-end"> |
| 58 | style={{ color: "var(--text-primary)" }} | 49 | <a href="/play" className="text-[#676767] no-underline"> |
| 59 | > | 50 | Play |
| 60 | Textbooks | 51 | </a> |
| 61 | </div> | 52 | <span className="mx-2">.</span> |
| 62 | <p className="text-sm"> | 53 | <a href="/problems" className="text-[#676767] no-underline"> |
| 63 | Problems pulled from qualified mathematical texts. Spivak, | 54 | Problems |
| 64 | Rudin, Artin, Munkres. | 55 | </a> |
| 65 | </p> | 56 | <span className="mx-2">.</span> |
| 66 | </div> | 57 | <a href="/sign-in" className="text-[#676767] no-underline"> |
| 67 | <div className="text-center"> | 58 | Sign in |
| 68 | <div | 59 | </a> |
| 69 | className="text-2xl font-semibold mb-1" | ||
| 70 | style={{ color: "var(--text-primary)" }} | ||
| 71 | > | ||
| 72 | Paths | ||
| 73 | </div> | ||
| 74 | <p className="text-sm"> | ||
| 75 | Work through a textbook end to end. Track your progress across | ||
| 76 | chapters. | ||
| 77 | </p> | ||
| 78 | </div> | ||
| 79 | <div className="text-center"> | ||
| 80 | <div | ||
| 81 | className="text-2xl font-semibold mb-1" | ||
| 82 | style={{ color: "var(--text-primary)" }} | ||
| 83 | > | ||
| 84 | Community | ||
| 85 | </div> | ||
| 86 | <p className="text-sm"> | ||
| 87 | Submit problems, discuss solutions, build your profile. Open | ||
| 88 | source questions. | ||
| 89 | </p> | ||
| 90 | </div> | ||
| 91 | </div> | 60 | </div> |
| 92 | </main> | ||
| 93 | <footer | ||
| 94 | className="py-4 text-center text-xs border-t" | ||
| 95 | style={{ | ||
| 96 | color: "var(--text-muted)", | ||
| 97 | borderColor: "var(--border-color)", | ||
| 98 | background: "var(--bg-primary)", | ||
| 99 | }} | ||
| 100 | > | ||
| 101 | SumTeX by Kris Yotam | ||
| 102 | </footer> | 61 | </footer> |
| 103 | </div> | 62 | </main> |
| 104 | ); | 63 | ); |
| 105 | } | 64 | } |
| @@ -0,0 +1,9 @@ | |||
| 1 | import { SignIn } from "@clerk/nextjs"; | ||
| 2 | |||
| 3 | export default function SignInPage() { | ||
| 4 | return ( | ||
| 5 | <main className="auth-shell"> | ||
| 6 | <SignIn routing="path" path="/sign-in" signUpUrl="/sign-up" /> | ||
| 7 | </main> | ||
| 8 | ); | ||
| 9 | } | ||
| @@ -0,0 +1,9 @@ | |||
| 1 | import { SignUp } from "@clerk/nextjs"; | ||
| 2 | |||
| 3 | export default function SignUpPage() { | ||
| 4 | return ( | ||
| 5 | <main className="auth-shell"> | ||
| 6 | <SignUp routing="path" path="/sign-up" signInUrl="/sign-in" /> | ||
| 7 | </main> | ||
| 8 | ); | ||
| 9 | } | ||
| @@ -0,0 +1,673 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 4 | import { useRouter, useSearchParams } from "next/navigation"; | ||
| 5 | import { SurfaceNav } from "@/components/navigation/surfaceNav"; | ||
| 6 | import { | ||
| 7 | arithmeticCategories, | ||
| 8 | checkArithmeticAnswer, | ||
| 9 | createSeededRng, | ||
| 10 | defaultCustomConfig, | ||
| 11 | generateArithmeticProblem, | ||
| 12 | scoreArithmeticAttempt, | ||
| 13 | type ArithmeticCategory, | ||
| 14 | type ArithmeticCustomConfig, | ||
| 15 | type ArithmeticProblem, | ||
| 16 | type ArithmeticRunMode, | ||
| 17 | type ArithmeticSessionConfig, | ||
| 18 | type ArithmeticStar, | ||
| 19 | type ArithmeticSubmode, | ||
| 20 | type BigMagnitude, | ||
| 21 | } from "@/lib/arithmetic"; | ||
| 22 | |||
| 23 | type AttemptRow = { | ||
| 24 | expression: string; | ||
| 25 | answer: string; | ||
| 26 | userAnswer: string; | ||
| 27 | correct: boolean; | ||
| 28 | }; | ||
| 29 | |||
| 30 | const runModes: Array<{ id: ArithmeticRunMode; label: string }> = [ | ||
| 31 | { id: "untimed", label: "Practice" }, | ||
| 32 | { id: "30s", label: "30s" }, | ||
| 33 | { id: "60s", label: "60s" }, | ||
| 34 | { id: "120s", label: "120s" }, | ||
| 35 | { id: "300s", label: "300s" }, | ||
| 36 | ]; | ||
| 37 | |||
| 38 | const submodes: Array<{ id: ArithmeticSubmode; label: string }> = [ | ||
| 39 | { id: "sqrt", label: "sqrt n" }, | ||
| 40 | { id: "square", label: "n^2" }, | ||
| 41 | { id: "cube", label: "n^3" }, | ||
| 42 | ]; | ||
| 43 | |||
| 44 | const magnitudes: Array<{ id: BigMagnitude; label: string }> = [ | ||
| 45 | { id: "thousands", label: "Thousands" }, | ||
| 46 | { id: "millions", label: "Millions" }, | ||
| 47 | { id: "billions", label: "Billions" }, | ||
| 48 | ]; | ||
| 49 | |||
| 50 | export function MentalArithmeticPlay() { | ||
| 51 | const router = useRouter(); | ||
| 52 | const searchParams = useSearchParams(); | ||
| 53 | const [category, setCategory] = useState<ArithmeticCategory>(() => parseCategory(searchParams.get("category"))); | ||
| 54 | const [star, setStar] = useState<ArithmeticStar>(() => parseStar(searchParams.get("star"))); | ||
| 55 | const [runMode, setRunMode] = useState<ArithmeticRunMode>("60s"); | ||
| 56 | const [submode, setSubmode] = useState<ArithmeticSubmode>("square"); | ||
| 57 | const [magnitude, setMagnitude] = useState<BigMagnitude>("thousands"); | ||
| 58 | const [customEnabled, setCustomEnabled] = useState(false); | ||
| 59 | const [customConfig, setCustomConfig] = useState<ArithmeticCustomConfig>(defaultCustomConfig); | ||
| 60 | const [sessionId, setSessionId] = useState<string | null>(null); | ||
| 61 | const [seed, setSeed] = useState(1); | ||
| 62 | const [problem, setProblem] = useState<ArithmeticProblem | null>(null); | ||
| 63 | const [answer, setAnswer] = useState(""); | ||
| 64 | const [startedAt, setStartedAt] = useState<number | null>(null); | ||
| 65 | const [problemStartedAt, setProblemStartedAt] = useState<number | null>(null); | ||
| 66 | const [timeLeft, setTimeLeft] = useState<number | null>(60); | ||
| 67 | const [running, setRunning] = useState(false); | ||
| 68 | const [ended, setEnded] = useState(false); | ||
| 69 | const [score, setScore] = useState(0); | ||
| 70 | const [total, setTotal] = useState(0); | ||
| 71 | const [correct, setCorrect] = useState(0); | ||
| 72 | const [recent, setRecent] = useState<AttemptRow[]>([]); | ||
| 73 | const [feedback, setFeedback] = useState<"correct" | "wrong" | null>(null); | ||
| 74 | const rngRef = useRef(createSeededRng(1)); | ||
| 75 | const inputRef = useRef<HTMLInputElement>(null); | ||
| 76 | |||
| 77 | const config = useMemo<ArithmeticSessionConfig>( | ||
| 78 | () => ({ | ||
| 79 | mode: customEnabled ? "custom" : "standard", | ||
| 80 | category, | ||
| 81 | submode: category === "roots-powers" ? submode : undefined, | ||
| 82 | star, | ||
| 83 | runMode, | ||
| 84 | magnitude: category.startsWith("big-") ? magnitude : undefined, | ||
| 85 | customConfig: customEnabled ? customConfig : undefined, | ||
| 86 | }), | ||
| 87 | [category, customConfig, customEnabled, magnitude, runMode, star, submode] | ||
| 88 | ); | ||
| 89 | |||
| 90 | const duration = runMode === "untimed" ? null : Number(runMode.replace("s", "")); | ||
| 91 | const binaryKeypad = problem?.answerFormat === "binary"; | ||
| 92 | const selectedCategory = arithmeticCategories.find((item) => item.id === category); | ||
| 93 | const pageTitle = customEnabled | ||
| 94 | ? `Custom arithmetic · ${star} ${star === 1 ? "star" : "stars"}` | ||
| 95 | : `${selectedCategory?.label ?? "Arithmetic"} · ${star} ${star === 1 ? "star" : "stars"}`; | ||
| 96 | |||
| 97 | const nextProblem = useCallback(() => { | ||
| 98 | const generated = generateArithmeticProblem(config, rngRef.current); | ||
| 99 | setProblem(generated); | ||
| 100 | setProblemStartedAt(Date.now()); | ||
| 101 | setAnswer(""); | ||
| 102 | setFeedback(null); | ||
| 103 | requestAnimationFrame(() => inputRef.current?.focus()); | ||
| 104 | }, [config]); | ||
| 105 | |||
| 106 | const start = useCallback(async () => { | ||
| 107 | const nextSeed = Math.floor(Math.random() * 2_147_483_647); | ||
| 108 | rngRef.current = createSeededRng(nextSeed); | ||
| 109 | setSeed(nextSeed); | ||
| 110 | setScore(0); | ||
| 111 | setTotal(0); | ||
| 112 | setCorrect(0); | ||
| 113 | setRecent([]); | ||
| 114 | setStartedAt(Date.now()); | ||
| 115 | setTimeLeft(duration); | ||
| 116 | setRunning(true); | ||
| 117 | setEnded(false); | ||
| 118 | setSessionId(null); | ||
| 119 | |||
| 120 | const generated = generateArithmeticProblem(config, rngRef.current); | ||
| 121 | setProblem(generated); | ||
| 122 | setProblemStartedAt(Date.now()); | ||
| 123 | setAnswer(""); | ||
| 124 | |||
| 125 | const response = await fetch("/api/arithmetic/sessions", { | ||
| 126 | method: "POST", | ||
| 127 | headers: { "content-type": "application/json" }, | ||
| 128 | body: JSON.stringify({ config, seed: nextSeed }), | ||
| 129 | }); | ||
| 130 | const data = (await response.json()) as { sessionId?: string | null }; | ||
| 131 | setSessionId(data.sessionId ?? null); | ||
| 132 | |||
| 133 | requestAnimationFrame(() => inputRef.current?.focus()); | ||
| 134 | }, [config, duration]); | ||
| 135 | |||
| 136 | const end = useCallback( | ||
| 137 | async (override?: { score: number; total: number; correct: number; sessionId: string | null }) => { | ||
| 138 | setRunning(false); | ||
| 139 | setEnded(true); | ||
| 140 | setProblem(null); | ||
| 141 | |||
| 142 | const payload = { | ||
| 143 | sessionId: override?.sessionId ?? sessionId, | ||
| 144 | score: override?.score ?? score, | ||
| 145 | totalAttempts: override?.total ?? total, | ||
| 146 | correctAttempts: override?.correct ?? correct, | ||
| 147 | }; | ||
| 148 | |||
| 149 | if (payload.sessionId) { | ||
| 150 | await fetch("/api/arithmetic/end", { | ||
| 151 | method: "POST", | ||
| 152 | headers: { "content-type": "application/json" }, | ||
| 153 | body: JSON.stringify(payload), | ||
| 154 | }); | ||
| 155 | } | ||
| 156 | }, | ||
| 157 | [correct, score, sessionId, total] | ||
| 158 | ); | ||
| 159 | |||
| 160 | const submit = useCallback(async () => { | ||
| 161 | if (!problem || !running || !problemStartedAt || !answer.trim()) { | ||
| 162 | return; | ||
| 163 | } | ||
| 164 | |||
| 165 | const elapsedMs = Date.now() - problemStartedAt; | ||
| 166 | const isCorrect = checkArithmeticAnswer(problem, answer); | ||
| 167 | const scoreDelta = scoreArithmeticAttempt( | ||
| 168 | { correct: isCorrect, elapsedMs, problem }, | ||
| 169 | config | ||
| 170 | ); | ||
| 171 | const nextScore = score + scoreDelta; | ||
| 172 | const nextTotal = total + 1; | ||
| 173 | const nextCorrect = correct + (isCorrect ? 1 : 0); | ||
| 174 | |||
| 175 | setScore(nextScore); | ||
| 176 | setTotal(nextTotal); | ||
| 177 | setCorrect(nextCorrect); | ||
| 178 | setFeedback(isCorrect ? "correct" : "wrong"); | ||
| 179 | setRecent((items) => | ||
| 180 | [ | ||
| 181 | { | ||
| 182 | expression: problem.expression, | ||
| 183 | answer: problem.canonicalAnswer, | ||
| 184 | userAnswer: answer, | ||
| 185 | correct: isCorrect, | ||
| 186 | }, | ||
| 187 | ...items, | ||
| 188 | ].slice(0, 8) | ||
| 189 | ); | ||
| 190 | |||
| 191 | if (sessionId) { | ||
| 192 | void fetch("/api/arithmetic/attempts", { | ||
| 193 | method: "POST", | ||
| 194 | headers: { "content-type": "application/json" }, | ||
| 195 | body: JSON.stringify({ | ||
| 196 | sessionId, | ||
| 197 | problem, | ||
| 198 | userAnswer: answer, | ||
| 199 | correct: isCorrect, | ||
| 200 | elapsedMs, | ||
| 201 | scoreDelta, | ||
| 202 | }), | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | |||
| 206 | nextProblem(); | ||
| 207 | }, [answer, config, correct, nextProblem, problem, problemStartedAt, running, score, sessionId, total]); | ||
| 208 | |||
| 209 | useEffect(() => { | ||
| 210 | if (!running || duration === null || startedAt === null) { | ||
| 211 | return; | ||
| 212 | } | ||
| 213 | |||
| 214 | const interval = window.setInterval(() => { | ||
| 215 | const remaining = Math.max(0, duration - Math.floor((Date.now() - startedAt) / 1000)); | ||
| 216 | setTimeLeft(remaining); | ||
| 217 | if (remaining === 0) { | ||
| 218 | window.clearInterval(interval); | ||
| 219 | void end(); | ||
| 220 | } | ||
| 221 | }, 250); | ||
| 222 | |||
| 223 | return () => window.clearInterval(interval); | ||
| 224 | }, [duration, end, running, startedAt]); | ||
| 225 | |||
| 226 | useEffect(() => { | ||
| 227 | const handler = (event: KeyboardEvent) => { | ||
| 228 | if (!running) { | ||
| 229 | return; | ||
| 230 | } | ||
| 231 | |||
| 232 | const allowed = binaryKeypad ? /^[01]$/ : /^[0-9./-]$/; | ||
| 233 | if (allowed.test(event.key)) { | ||
| 234 | event.preventDefault(); | ||
| 235 | setAnswer((value) => value + event.key); | ||
| 236 | } else if (event.key === "Backspace") { | ||
| 237 | event.preventDefault(); | ||
| 238 | setAnswer((value) => value.slice(0, -1)); | ||
| 239 | } else if (event.key === "Enter") { | ||
| 240 | event.preventDefault(); | ||
| 241 | void submit(); | ||
| 242 | } | ||
| 243 | }; | ||
| 244 | |||
| 245 | window.addEventListener("keydown", handler); | ||
| 246 | return () => window.removeEventListener("keydown", handler); | ||
| 247 | }, [binaryKeypad, running, submit]); | ||
| 248 | |||
| 249 | useEffect(() => { | ||
| 250 | const params = new URLSearchParams(searchParams.toString()); | ||
| 251 | params.set("category", category); | ||
| 252 | params.set("star", String(star)); | ||
| 253 | router.replace(`/play/mental-arithmetic?${params.toString()}`, { scroll: false }); | ||
| 254 | }, [category, router, searchParams, star]); | ||
| 255 | |||
| 256 | function keypadPress(key: string) { | ||
| 257 | inputRef.current?.focus(); | ||
| 258 | if (key === "backspace") { | ||
| 259 | setAnswer((value) => value.slice(0, -1)); | ||
| 260 | } else if (key === "clear") { | ||
| 261 | setAnswer(""); | ||
| 262 | } else if (key === "enter") { | ||
| 263 | void submit(); | ||
| 264 | } else { | ||
| 265 | setAnswer((value) => value + key); | ||
| 266 | } | ||
| 267 | } | ||
| 268 | |||
| 269 | function toggleOperation(operation: ArithmeticCustomConfig["operations"][number]) { | ||
| 270 | setCustomConfig((current) => { | ||
| 271 | const hasOperation = current.operations.includes(operation); | ||
| 272 | const operations = hasOperation | ||
| 273 | ? current.operations.filter((item) => item !== operation) | ||
| 274 | : [...current.operations, operation]; | ||
| 275 | |||
| 276 | return { ...current, operations: operations.length ? operations : [operation] }; | ||
| 277 | }); | ||
| 278 | } | ||
| 279 | |||
| 280 | return ( | ||
| 281 | <> | ||
| 282 | <style>{mentalArithmeticStyles}</style> | ||
| 283 | <div className="ma-shell"> | ||
| 284 | <div className="ma-frame"> | ||
| 285 | <SurfaceNav variant="dark" labels="compact" /> | ||
| 286 | |||
| 287 | <header className="ma-hero"> | ||
| 288 | <div> | ||
| 289 | <p className="ma-kicker">SumTex Play</p> | ||
| 290 | <h1 className="ma-title">{pageTitle}</h1> | ||
| 291 | <p className="ma-subtitle"> | ||
| 292 | Fast generated arithmetic drills with timed runs, clean keyboard entry, and signed-in history. | ||
| 293 | </p> | ||
| 294 | </div> | ||
| 295 | <button type="button" onClick={running ? () => void end() : start} className="ma-primary-action"> | ||
| 296 | {running ? "End run" : "Start run"} | ||
| 297 | </button> | ||
| 298 | </header> | ||
| 299 | |||
| 300 | <div className="ma-layout"> | ||
| 301 | <aside className="ma-rail"> | ||
| 302 | <div className="ma-group"> | ||
| 303 | <div className="ma-label">Mode</div> | ||
| 304 | <label className="ma-toggle-row"> | ||
| 305 | <span>Custom mode</span> | ||
| 306 | <input | ||
| 307 | type="checkbox" | ||
| 308 | checked={customEnabled} | ||
| 309 | onChange={(event) => setCustomEnabled(event.target.checked)} | ||
| 310 | /> | ||
| 311 | </label> | ||
| 312 | </div> | ||
| 313 | |||
| 314 | {!customEnabled ? ( | ||
| 315 | <> | ||
| 316 | <div className="ma-group"> | ||
| 317 | <div className="ma-label">Category</div> | ||
| 318 | <div className="ma-category-list"> | ||
| 319 | {arithmeticCategories.map((item) => ( | ||
| 320 | <button | ||
| 321 | key={item.id} | ||
| 322 | type="button" | ||
| 323 | onClick={() => setCategory(item.id)} | ||
| 324 | className={`ma-line-option${category === item.id ? " active" : ""}`} | ||
| 325 | > | ||
| 326 | <span className="ma-option-name">{item.label}</span> | ||
| 327 | <span className="ma-option-meta">{item.description}</span> | ||
| 328 | </button> | ||
| 329 | ))} | ||
| 330 | </div> | ||
| 331 | </div> | ||
| 332 | |||
| 333 | {category === "roots-powers" ? ( | ||
| 334 | <div className="ma-group"> | ||
| 335 | <div className="ma-label">Submode</div> | ||
| 336 | <Segmented | ||
| 337 | items={submodes} | ||
| 338 | value={submode} | ||
| 339 | onChange={(value) => setSubmode(value as ArithmeticSubmode)} | ||
| 340 | /> | ||
| 341 | </div> | ||
| 342 | ) : null} | ||
| 343 | |||
| 344 | {category.startsWith("big-") ? ( | ||
| 345 | <div className="ma-group"> | ||
| 346 | <div className="ma-label">Magnitude</div> | ||
| 347 | <Segmented | ||
| 348 | items={magnitudes} | ||
| 349 | value={magnitude} | ||
| 350 | onChange={(value) => setMagnitude(value as BigMagnitude)} | ||
| 351 | /> | ||
| 352 | </div> | ||
| 353 | ) : null} | ||
| 354 | </> | ||
| 355 | ) : ( | ||
| 356 | <div className="ma-group"> | ||
| 357 | <div className="ma-label">Custom</div> | ||
| 358 | <div className="ma-pill-row"> | ||
| 359 | {(["addition", "subtraction", "multiplication", "division"] as const).map((operation) => ( | ||
| 360 | <button | ||
| 361 | key={operation} | ||
| 362 | type="button" | ||
| 363 | className={`ma-pill${customConfig.operations.includes(operation) ? " active" : ""}`} | ||
| 364 | onClick={() => toggleOperation(operation)} | ||
| 365 | > | ||
| 366 | {operation} | ||
| 367 | </button> | ||
| 368 | ))} | ||
| 369 | </div> | ||
| 370 | <NumberControl | ||
| 371 | label="Min digits" | ||
| 372 | value={customConfig.minDigits} | ||
| 373 | onChange={(value) => setCustomConfig((current) => ({ ...current, minDigits: value }))} | ||
| 374 | /> | ||
| 375 | <NumberControl | ||
| 376 | label="Max digits" | ||
| 377 | value={customConfig.maxDigits} | ||
| 378 | onChange={(value) => setCustomConfig((current) => ({ ...current, maxDigits: value }))} | ||
| 379 | /> | ||
| 380 | <CheckControl | ||
| 381 | label="Negatives" | ||
| 382 | checked={customConfig.allowNegatives} | ||
| 383 | onChange={(value) => setCustomConfig((current) => ({ ...current, allowNegatives: value }))} | ||
| 384 | /> | ||
| 385 | <CheckControl | ||
| 386 | label="Decimals" | ||
| 387 | checked={customConfig.allowDecimals} | ||
| 388 | onChange={(value) => setCustomConfig((current) => ({ ...current, allowDecimals: value }))} | ||
| 389 | /> | ||
| 390 | <CheckControl | ||
| 391 | label="Fractions" | ||
| 392 | checked={customConfig.allowFractions} | ||
| 393 | onChange={(value) => setCustomConfig((current) => ({ ...current, allowFractions: value }))} | ||
| 394 | /> | ||
| 395 | <CheckControl | ||
| 396 | label="Clean division" | ||
| 397 | checked={customConfig.cleanDivision} | ||
| 398 | onChange={(value) => setCustomConfig((current) => ({ ...current, cleanDivision: value }))} | ||
| 399 | /> | ||
| 400 | </div> | ||
| 401 | )} | ||
| 402 | |||
| 403 | <div className="ma-group"> | ||
| 404 | <div className="ma-label">Stars</div> | ||
| 405 | <div className="ma-pill-row"> | ||
| 406 | {([1, 2, 3, 4, 5] as ArithmeticStar[]).map((value) => ( | ||
| 407 | <button | ||
| 408 | key={value} | ||
| 409 | type="button" | ||
| 410 | className={`ma-pill${star === value ? " active" : ""}`} | ||
| 411 | onClick={() => setStar(value)} | ||
| 412 | > | ||
| 413 | {value} | ||
| 414 | </button> | ||
| 415 | ))} | ||
| 416 | </div> | ||
| 417 | </div> | ||
| 418 | |||
| 419 | <div className="ma-group"> | ||
| 420 | <div className="ma-label">Run</div> | ||
| 421 | <Segmented items={runModes} value={runMode} onChange={(value) => setRunMode(value as ArithmeticRunMode)} /> | ||
| 422 | </div> | ||
| 423 | </aside> | ||
| 424 | |||
| 425 | <section className="ma-stage"> | ||
| 426 | <div className="ma-metrics"> | ||
| 427 | <Metric label="Score" value={score} /> | ||
| 428 | <Metric label={runMode === "untimed" ? "Mode" : "Time"} value={runMode === "untimed" ? "Open" : `${timeLeft ?? duration}s`} /> | ||
| 429 | <Metric label="Accuracy" value={total ? `${Math.round((correct / total) * 100)}%` : "0%"} /> | ||
| 430 | </div> | ||
| 431 | |||
| 432 | {problem ? ( | ||
| 433 | <div className="ma-problem-wrap"> | ||
| 434 | <div className={`ma-expression${feedback ? ` ${feedback}` : ""}`}>{problem.expression}</div> | ||
| 435 | |||
| 436 | <input | ||
| 437 | ref={inputRef} | ||
| 438 | value={answer} | ||
| 439 | onChange={(event) => setAnswer(event.target.value)} | ||
| 440 | className="ma-answer" | ||
| 441 | inputMode={binaryKeypad ? "numeric" : "decimal"} | ||
| 442 | autoFocus | ||
| 443 | /> | ||
| 444 | |||
| 445 | <Keypad binary={binaryKeypad} onPress={keypadPress} /> | ||
| 446 | </div> | ||
| 447 | ) : ( | ||
| 448 | <div className="ma-empty"> | ||
| 449 | <h2>{ended ? "Run complete" : "Ready"}</h2> | ||
| 450 | <p> | ||
| 451 | {ended && !sessionId | ||
| 452 | ? "Guest runs stay on this device. Sign in to save future arithmetic history." | ||
| 453 | : "Choose a drill and start. The answer input stays keypad-first."} | ||
| 454 | </p> | ||
| 455 | </div> | ||
| 456 | )} | ||
| 457 | |||
| 458 | {recent.length ? ( | ||
| 459 | <div className="ma-recent"> | ||
| 460 | <h3>Recent attempts</h3> | ||
| 461 | <div> | ||
| 462 | {recent.map((item, index) => ( | ||
| 463 | <div key={`${item.expression}-${index}`} className="ma-recent-row"> | ||
| 464 | <span> | ||
| 465 | {item.expression} = {item.userAnswer || "blank"} | ||
| 466 | </span> | ||
| 467 | <span style={{ color: item.correct ? "#7fb68d" : "#c98462" }}> | ||
| 468 | {item.correct ? "Correct" : item.answer} | ||
| 469 | </span> | ||
| 470 | </div> | ||
| 471 | ))} | ||
| 472 | </div> | ||
| 473 | </div> | ||
| 474 | ) : null} | ||
| 475 | </section> | ||
| 476 | </div> | ||
| 477 | </div> | ||
| 478 | </div> | ||
| 479 | </> | ||
| 480 | ); | ||
| 481 | } | ||
| 482 | |||
| 483 | function Keypad({ binary, onPress }: { binary: boolean; onPress: (key: string) => void }) { | ||
| 484 | const keys = binary | ||
| 485 | ? ["1", "0", "backspace", "clear", "enter"] | ||
| 486 | : ["7", "8", "9", "backspace", "4", "5", "6", "clear", "1", "2", "3", "-", "0", ".", "/", "enter"]; | ||
| 487 | |||
| 488 | return ( | ||
| 489 | <div className="ma-keypad"> | ||
| 490 | {keys.map((key) => ( | ||
| 491 | <button | ||
| 492 | key={key} | ||
| 493 | type="button" | ||
| 494 | className="ma-key" | ||
| 495 | onClick={() => onPress(key)} | ||
| 496 | > | ||
| 497 | {key === "backspace" ? "Bksp" : key === "clear" ? "C" : key === "enter" ? "Enter" : key} | ||
| 498 | </button> | ||
| 499 | ))} | ||
| 500 | </div> | ||
| 501 | ); | ||
| 502 | } | ||
| 503 | |||
| 504 | function Segmented({ | ||
| 505 | items, | ||
| 506 | value, | ||
| 507 | onChange, | ||
| 508 | }: { | ||
| 509 | items: Array<{ id: string; label: string }>; | ||
| 510 | value: string; | ||
| 511 | onChange: (value: string) => void; | ||
| 512 | }) { | ||
| 513 | return ( | ||
| 514 | <div className="ma-pill-row"> | ||
| 515 | {items.map((item) => ( | ||
| 516 | <button | ||
| 517 | key={item.id} | ||
| 518 | type="button" | ||
| 519 | className={`ma-pill${value === item.id ? " active" : ""}`} | ||
| 520 | onClick={() => onChange(item.id)} | ||
| 521 | > | ||
| 522 | {item.label} | ||
| 523 | </button> | ||
| 524 | ))} | ||
| 525 | </div> | ||
| 526 | ); | ||
| 527 | } | ||
| 528 | |||
| 529 | function NumberControl({ | ||
| 530 | label, | ||
| 531 | value, | ||
| 532 | onChange, | ||
| 533 | }: { | ||
| 534 | label: string; | ||
| 535 | value: number; | ||
| 536 | onChange: (value: number) => void; | ||
| 537 | }) { | ||
| 538 | return ( | ||
| 539 | <label className="ma-number-row"> | ||
| 540 | {label} | ||
| 541 | <input | ||
| 542 | type="number" | ||
| 543 | min={1} | ||
| 544 | max={9} | ||
| 545 | value={value} | ||
| 546 | onChange={(event) => onChange(Number(event.target.value))} | ||
| 547 | /> | ||
| 548 | </label> | ||
| 549 | ); | ||
| 550 | } | ||
| 551 | |||
| 552 | function CheckControl({ | ||
| 553 | label, | ||
| 554 | checked, | ||
| 555 | onChange, | ||
| 556 | }: { | ||
| 557 | label: string; | ||
| 558 | checked: boolean; | ||
| 559 | onChange: (value: boolean) => void; | ||
| 560 | }) { | ||
| 561 | return ( | ||
| 562 | <label className="ma-toggle-row"> | ||
| 563 | {label} | ||
| 564 | <input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} /> | ||
| 565 | </label> | ||
| 566 | ); | ||
| 567 | } | ||
| 568 | |||
| 569 | function Metric({ label, value }: { label: string; value: string | number }) { | ||
| 570 | return ( | ||
| 571 | <div className="ma-metric"> | ||
| 572 | <div className="ma-metric-label">{label}</div> | ||
| 573 | <div className="ma-metric-value">{value}</div> | ||
| 574 | </div> | ||
| 575 | ); | ||
| 576 | } | ||
| 577 | |||
| 578 | function parseCategory(value: string | null): ArithmeticCategory { | ||
| 579 | return arithmeticCategories.some((item) => item.id === value) | ||
| 580 | ? (value as ArithmeticCategory) | ||
| 581 | : "addition"; | ||
| 582 | } | ||
| 583 | |||
| 584 | function parseStar(value: string | null): ArithmeticStar { | ||
| 585 | const numeric = Number(value); | ||
| 586 | return ([1, 2, 3, 4, 5] as ArithmeticStar[]).includes(numeric as ArithmeticStar) | ||
| 587 | ? (numeric as ArithmeticStar) | ||
| 588 | : 2; | ||
| 589 | } | ||
| 590 | |||
| 591 | const mentalArithmeticStyles = ` | ||
| 592 | .ma-shell { | ||
| 593 | --surface-bg: #ffffff; | ||
| 594 | --surface-bg-soft: #f6f6f6; | ||
| 595 | --surface-control: #ffffff; | ||
| 596 | --surface-text: #171717; | ||
| 597 | --surface-strong: #000000; | ||
| 598 | --surface-muted: #555555; | ||
| 599 | --surface-faint: #8a8a8a; | ||
| 600 | --surface-line: #dedede; | ||
| 601 | --surface-line-soft: #e9e9e9; | ||
| 602 | --surface-invert: #000000; | ||
| 603 | --surface-invert-text: #ffffff; | ||
| 604 | min-height: 100vh; | ||
| 605 | background: var(--surface-bg); | ||
| 606 | color: var(--surface-text); | ||
| 607 | padding: 28px 5vw 56px; | ||
| 608 | } | ||
| 609 | .dark .ma-shell { | ||
| 610 | --surface-bg: #080808; | ||
| 611 | --surface-bg-soft: #0d0d0d; | ||
| 612 | --surface-control: #111; | ||
| 613 | --surface-text: #f4f2ee; | ||
| 614 | --surface-strong: #f5f3ef; | ||
| 615 | --surface-muted: #8d8d8d; | ||
| 616 | --surface-faint: #777; | ||
| 617 | --surface-line: #242424; | ||
| 618 | --surface-line-soft: #1d1d1d; | ||
| 619 | --surface-invert: #f1eee8; | ||
| 620 | --surface-invert-text: #080808; | ||
| 621 | } | ||
| 622 | .ma-shell a { color: inherit; text-decoration: none; } | ||
| 623 | .ma-frame { margin: 0 auto; max-width: 1440px; } | ||
| 624 | .ma-hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; margin: 0 0 46px; } | ||
| 625 | .ma-kicker, .ma-label, .ma-metric-label, .ma-recent h3 { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; line-height: 1; text-transform: uppercase; } | ||
| 626 | .ma-kicker { font-size: 13px; letter-spacing: 0.18em; margin: 0 0 22px; } | ||
| 627 | .ma-title { color: var(--surface-strong); font-size: clamp(44px, 6vw, 92px); font-weight: 400; letter-spacing: 0; line-height: 0.95; margin: 0; } | ||
| 628 | .ma-subtitle { color: var(--surface-muted); font-size: 18px; line-height: 1.5; margin: 22px 0 0; max-width: 620px; } | ||
| 629 | .ma-primary-action { align-items: center; background: transparent; border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); cursor: pointer; display: inline-flex; font-family: var(--font-mono); font-size: 13px; font-weight: 600; justify-content: center; letter-spacing: 0.14em; min-height: 54px; padding: 0 28px; text-transform: uppercase; white-space: nowrap; } | ||
| 630 | .ma-primary-action:hover { background: var(--surface-bg-soft); border-color: var(--surface-muted); } | ||
| 631 | .ma-layout { align-items: start; display: grid; gap: 44px; grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); } | ||
| 632 | .ma-rail { border-left: 1px solid var(--surface-line); display: flex; flex-direction: column; gap: 32px; padding-left: 26px; } | ||
| 633 | .ma-group { display: grid; gap: 12px; } | ||
| 634 | .ma-category-list { display: grid; gap: 1px; } | ||
| 635 | .ma-line-option, .ma-toggle-row, .ma-number-row { background: transparent; border: 0; border-bottom: 1px solid var(--surface-line-soft); color: var(--surface-text); display: flex; gap: 16px; justify-content: space-between; min-height: 46px; padding: 12px 0; text-align: left; } | ||
| 636 | .ma-line-option { cursor: pointer; } | ||
| 637 | .ma-line-option:hover, .ma-line-option.active { color: var(--surface-strong); } | ||
| 638 | .ma-option-name { align-items: center; display: inline-flex; font-size: 15px; gap: 10px; } | ||
| 639 | .ma-option-name:before { background: var(--surface-line); border-radius: 999px; content: ""; display: inline-block; height: 7px; width: 7px; } | ||
| 640 | .ma-line-option.active .ma-option-name:before { background: var(--surface-strong); } | ||
| 641 | .ma-option-meta { color: var(--surface-faint); font-size: 12px; max-width: 130px; text-align: right; } | ||
| 642 | .ma-pill-row { display: flex; flex-wrap: wrap; gap: 8px; } | ||
| 643 | .ma-pill { background: transparent; border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-muted); cursor: pointer; font-family: var(--font-mono); font-size: 12px; min-height: 36px; padding: 0 14px; text-transform: uppercase; } | ||
| 644 | .ma-pill.active { background: var(--surface-invert); border-color: var(--surface-invert); color: var(--surface-invert-text); } | ||
| 645 | .ma-stage { border-left: 1px solid var(--surface-line); min-height: 650px; padding-left: clamp(28px, 5vw, 76px); } | ||
| 646 | .ma-metrics { display: grid; gap: 1px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 0 42px; } | ||
| 647 | .ma-metric { border-top: 1px solid var(--surface-line); padding: 18px 22px 0 0; } | ||
| 648 | .ma-metric-value { color: var(--surface-strong); font-family: var(--font-mono); font-size: 30px; line-height: 1.2; margin-top: 8px; } | ||
| 649 | .ma-problem-wrap { margin: 0 auto; max-width: 760px; text-align: center; } | ||
| 650 | .ma-expression { color: var(--surface-strong); font-family: var(--font-mono); font-size: clamp(46px, 7vw, 96px); font-weight: 500; letter-spacing: 0; line-height: 1.05; margin: 50px 0 34px; min-height: 110px; } | ||
| 651 | .ma-expression.correct { color: #7fb68d; } | ||
| 652 | .ma-expression.wrong { color: #c98462; } | ||
| 653 | .ma-answer { background: var(--surface-control); border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); font-family: var(--font-mono); font-size: 28px; height: 66px; outline: none; padding: 0 26px; text-align: center; width: min(100%, 560px); } | ||
| 654 | .ma-answer:focus { border-color: var(--surface-muted); } | ||
| 655 | .ma-keypad { display: grid; gap: 10px; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 24px auto 0; max-width: 560px; } | ||
| 656 | .ma-key { background: var(--surface-control); border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-text); cursor: pointer; font-family: var(--font-mono); font-size: 15px; height: 52px; } | ||
| 657 | .ma-key:hover { background: var(--surface-bg-soft); border-color: var(--surface-muted); } | ||
| 658 | .ma-empty { align-items: center; display: flex; flex-direction: column; justify-content: center; min-height: 450px; text-align: center; } | ||
| 659 | .ma-empty h2 { color: var(--surface-strong); font-size: clamp(42px, 6vw, 82px); font-weight: 400; letter-spacing: 0; line-height: 1; margin: 0; } | ||
| 660 | .ma-empty p { color: var(--surface-muted); font-size: 17px; line-height: 1.5; margin: 20px 0 0; max-width: 520px; } | ||
| 661 | .ma-recent { border-top: 1px solid var(--surface-line); margin-top: 52px; padding-top: 22px; } | ||
| 662 | .ma-recent h3 { margin: 0 0 14px; } | ||
| 663 | .ma-recent-row { border-bottom: 1px solid var(--surface-line-soft); color: var(--surface-muted); display: grid; font-family: var(--font-mono); font-size: 13px; gap: 16px; grid-template-columns: 1fr auto; padding: 12px 0; } | ||
| 664 | .ma-number-row, .ma-toggle-row { align-items: center; cursor: default; } | ||
| 665 | .ma-number-row input { background: var(--surface-control); border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); height: 34px; padding: 0 12px; text-align: center; width: 78px; } | ||
| 666 | .ma-toggle-row input { accent-color: var(--surface-invert); } | ||
| 667 | @media (max-width: 900px) { | ||
| 668 | .ma-shell { padding: 28px 20px 48px; } | ||
| 669 | .ma-hero { align-items: stretch; flex-direction: column; margin-bottom: 34px; } | ||
| 670 | .ma-layout { grid-template-columns: 1fr; } | ||
| 671 | .ma-rail, .ma-stage { border-left: 0; padding-left: 0; } | ||
| 672 | } | ||
| 673 | `; | ||
| @@ -8,6 +8,12 @@ interface MathDisplayProps { · 7 unmodified lines | |||
| 8 | } | 8 | } |
| 9 | 9 | ||
| 10 | export function MathDisplay({ latex, inline = false }: MathDisplayProps) { | 10 | export function MathDisplay({ latex, inline = false }: MathDisplayProps) { |
| 11 | const wrapped = inline ? `$${latex}$` : `$$${latex}$$`; | 11 | return ( |
| 12 | return <MathJax>{wrapped}</MathJax>; | 12 | <span className={inline ? "math-display math-display-inline" : "math-display math-display-block"}> |
| 13 | <span className="math-display-fallback">{latex}</span> | ||
| 14 | <span className="math-display-render"> | ||
| 15 | <MathJax dynamic inline={inline}>{inline ? `$${latex}$` : `$$${latex}$$`}</MathJax> | ||
| 16 | </span> | ||
| 17 | </span> | ||
| 18 | ); | ||
| 13 | } | 19 | } |
| @@ -0,0 +1,59 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { Show, SignInButton, UserButton } from "@clerk/nextjs"; | ||
| 4 | import { ThemeToggle } from "@/components/navigation/themeToggle"; | ||
| 5 | |||
| 6 | const navLinks = [ | ||
| 7 | { href: "/play", label: "Play" }, | ||
| 8 | { href: "/problems", label: "Problems" }, | ||
| 9 | { href: "/paths", label: "Guides" }, | ||
| 10 | { href: "/roadmaps", label: "Roadmaps" }, | ||
| 11 | { href: "/collections", label: "Collections" }, | ||
| 12 | { href: "/forum", label: "Forum" }, | ||
| 13 | { href: "/leaderboard", label: "Leaderboard" }, | ||
| 14 | { href: "/profile", label: "Profile" }, | ||
| 15 | ]; | ||
| 16 | |||
| 17 | export function SurfaceNav({ | ||
| 18 | variant = "dark", | ||
| 19 | labels = "full", | ||
| 20 | }: { | ||
| 21 | variant?: "dark" | "light"; | ||
| 22 | labels?: "full" | "compact"; | ||
| 23 | }) { | ||
| 24 | return ( | ||
| 25 | <nav className={`surface-nav surface-nav-${variant}`} aria-label="Primary"> | ||
| 26 | <a className="surface-brand" href="/"> | ||
| 27 | SumTex | ||
| 28 | </a> | ||
| 29 | <div className="surface-nav-links"> | ||
| 30 | {navLinks.map((link) => ( | ||
| 31 | <a key={link.href} href={link.href}> | ||
| 32 | {labels === "compact" && link.label === "Play" ? "Modes" : link.label} | ||
| 33 | </a> | ||
| 34 | ))} | ||
| 35 | <ThemeToggle className="surface-theme-toggle" /> | ||
| 36 | <Show | ||
| 37 | when="signed-in" | ||
| 38 | fallback={ | ||
| 39 | <SignInButton mode="modal"> | ||
| 40 | <button type="button" className="surface-auth-button"> | ||
| 41 | Sign in | ||
| 42 | </button> | ||
| 43 | </SignInButton> | ||
| 44 | } | ||
| 45 | > | ||
| 46 | <UserButton | ||
| 47 | appearance={{ | ||
| 48 | elements: { | ||
| 49 | avatarBox: "surface-user-avatar", | ||
| 50 | userButtonPopoverCard: "surface-user-popover", | ||
| 51 | }, | ||
| 52 | }} | ||
| 53 | userProfileUrl="/profile/settings" | ||
| 54 | /> | ||
| 55 | </Show> | ||
| 56 | </div> | ||
| 57 | </nav> | ||
| 58 | ); | ||
| 59 | } | ||
| @@ -0,0 +1,56 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useTheme } from "@/lib/theme"; | ||
| 4 | |||
| 5 | export function ThemeToggle({ className = "" }: { className?: string }) { | ||
| 6 | const { theme, toggle } = useTheme(); | ||
| 7 | const nextTheme = theme === "light" ? "dark" : "light"; | ||
| 8 | |||
| 9 | return ( | ||
| 10 | <button | ||
| 11 | type="button" | ||
| 12 | onClick={toggle} | ||
| 13 | className={className} | ||
| 14 | aria-label={`Switch to ${nextTheme} mode`} | ||
| 15 | title={`Switch to ${nextTheme} mode`} | ||
| 16 | > | ||
| 17 | {theme === "light" ? ( | ||
| 18 | <svg | ||
| 19 | aria-hidden="true" | ||
| 20 | width="16" | ||
| 21 | height="16" | ||
| 22 | viewBox="0 0 24 24" | ||
| 23 | fill="none" | ||
| 24 | stroke="currentColor" | ||
| 25 | strokeWidth="2" | ||
| 26 | strokeLinecap="round" | ||
| 27 | strokeLinejoin="round" | ||
| 28 | > | ||
| 29 | <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> | ||
| 30 | </svg> | ||
| 31 | ) : ( | ||
| 32 | <svg | ||
| 33 | aria-hidden="true" | ||
| 34 | width="16" | ||
| 35 | height="16" | ||
| 36 | viewBox="0 0 24 24" | ||
| 37 | fill="none" | ||
| 38 | stroke="currentColor" | ||
| 39 | strokeWidth="2" | ||
| 40 | strokeLinecap="round" | ||
| 41 | strokeLinejoin="round" | ||
| 42 | > | ||
| 43 | <circle cx="12" cy="12" r="5" /> | ||
| 44 | <line x1="12" y1="1" x2="12" y2="3" /> | ||
| 45 | <line x1="12" y1="21" x2="12" y2="23" /> | ||
| 46 | <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /> | ||
| 47 | <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /> | ||
| 48 | <line x1="1" y1="12" x2="3" y2="12" /> | ||
| 49 | <line x1="21" y1="12" x2="23" y2="12" /> | ||
| 50 | <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /> | ||
| 51 | <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /> | ||
| 52 | </svg> | ||
| 53 | )} | ||
| 54 | </button> | ||
| 55 | ); | ||
| 56 | } | ||
| @@ -0,0 +1,393 @@ | |||
| 1 | import type { | ||
| 2 | ArithmeticAttemptResult, | ||
| 3 | ArithmeticCategory, | ||
| 4 | ArithmeticCustomConfig, | ||
| 5 | ArithmeticProblem, | ||
| 6 | ArithmeticSessionConfig, | ||
| 7 | ArithmeticStar, | ||
| 8 | AnswerFormat, | ||
| 9 | BigMagnitude, | ||
| 10 | Rng, | ||
| 11 | } from "./types"; | ||
| 12 | |||
| 13 | const STAR_DIGITS: Record<ArithmeticStar, [number, number]> = { | ||
| 14 | 1: [1, 1], | ||
| 15 | 2: [1, 2], | ||
| 16 | 3: [2, 2], | ||
| 17 | 4: [2, 3], | ||
| 18 | 5: [3, 3], | ||
| 19 | }; | ||
| 20 | |||
| 21 | const BIG_RANGES: Record<BigMagnitude, [number, number]> = { | ||
| 22 | thousands: [1_000, 9_999], | ||
| 23 | millions: [1_000_000, 9_999_999], | ||
| 24 | billions: [1_000_000_000, 9_999_999_999], | ||
| 25 | }; | ||
| 26 | |||
| 27 | export function createSeededRng(seed: number): Rng { | ||
| 28 | let state = seed >>> 0; | ||
| 29 | |||
| 30 | return { | ||
| 31 | next() { | ||
| 32 | state = (state + 0x6d2b79f5) >>> 0; | ||
| 33 | let t = state; | ||
| 34 | t = Math.imul(t ^ (t >>> 15), t | 1); | ||
| 35 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | ||
| 36 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | ||
| 37 | }, | ||
| 38 | }; | ||
| 39 | } | ||
| 40 | |||
| 41 | export function defaultCustomConfig(): ArithmeticCustomConfig { | ||
| 42 | return { | ||
| 43 | operations: ["addition", "subtraction", "multiplication", "division"], | ||
| 44 | minDigits: 1, | ||
| 45 | maxDigits: 2, | ||
| 46 | allowNegatives: false, | ||
| 47 | allowDecimals: false, | ||
| 48 | allowFractions: true, | ||
| 49 | cleanDivision: true, | ||
| 50 | percentMode: "part", | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | export function generateArithmeticProblem( | ||
| 55 | config: ArithmeticSessionConfig, | ||
| 56 | rng: Rng | ||
| 57 | ): ArithmeticProblem { | ||
| 58 | const category: ArithmeticCategory = | ||
| 59 | config.mode === "custom" ? pick(rng, config.customConfig?.operations ?? ["addition"]) : config.category; | ||
| 60 | const resolvedConfig = { ...config, category }; | ||
| 61 | |||
| 62 | switch (category) { | ||
| 63 | case "addition": | ||
| 64 | return binaryOpProblem(resolvedConfig, rng, "+", (a, b) => a + b); | ||
| 65 | case "subtraction": | ||
| 66 | return binaryOpProblem(resolvedConfig, rng, "-", (a, b) => a - b); | ||
| 67 | case "multiplication": | ||
| 68 | return binaryOpProblem(resolvedConfig, rng, "x", (a, b) => a * b); | ||
| 69 | case "division": | ||
| 70 | return divisionProblem(resolvedConfig, rng); | ||
| 71 | case "mixed": | ||
| 72 | return mixedProblem(resolvedConfig, rng); | ||
| 73 | case "roots-powers": | ||
| 74 | return rootsPowersProblem(resolvedConfig, rng); | ||
| 75 | case "percents": | ||
| 76 | return percentProblem(resolvedConfig, rng); | ||
| 77 | case "binary": | ||
| 78 | return binaryModeProblem(resolvedConfig, rng); | ||
| 79 | case "big-multiply": | ||
| 80 | return bigMultiplyProblem(resolvedConfig, rng); | ||
| 81 | case "big-divide": | ||
| 82 | return bigDivideProblem(resolvedConfig, rng); | ||
| 83 | case "big-percents": | ||
| 84 | return bigPercentProblem(resolvedConfig, rng); | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | export function checkArithmeticAnswer(problem: ArithmeticProblem, userAnswer: string): boolean { | ||
| 89 | if (problem.answerFormat === "binary") { | ||
| 90 | const cleaned = userAnswer.trim().replace(/^0b/i, ""); | ||
| 91 | return /^[01]+$/.test(cleaned) && cleaned.replace(/^0+(?=\d)/, "") === problem.canonicalAnswer; | ||
| 92 | } | ||
| 93 | |||
| 94 | const expected = parseRational(problem.canonicalAnswer); | ||
| 95 | const actual = parseRational(userAnswer); | ||
| 96 | |||
| 97 | if (!expected || !actual) { | ||
| 98 | return false; | ||
| 99 | } | ||
| 100 | |||
| 101 | return expected.num === actual.num && expected.den === actual.den; | ||
| 102 | } | ||
| 103 | |||
| 104 | export function scoreArithmeticAttempt( | ||
| 105 | attempt: Pick<ArithmeticAttemptResult, "correct" | "elapsedMs" | "problem">, | ||
| 106 | session: Pick<ArithmeticSessionConfig, "runMode"> | ||
| 107 | ): number { | ||
| 108 | if (!attempt.correct) { | ||
| 109 | return 0; | ||
| 110 | } | ||
| 111 | |||
| 112 | const star = attempt.problem.star; | ||
| 113 | const base = 8 + star * 4; | ||
| 114 | const speed = session.runMode === "untimed" ? 1 : Math.max(1, 4 - Math.floor(attempt.elapsedMs / 2500)); | ||
| 115 | const categoryBonus = attempt.problem.category.startsWith("big-") ? 8 : 0; | ||
| 116 | |||
| 117 | return base + speed + categoryBonus; | ||
| 118 | } | ||
| 119 | |||
| 120 | function binaryOpProblem( | ||
| 121 | config: ArithmeticSessionConfig, | ||
| 122 | rng: Rng, | ||
| 123 | symbol: string, | ||
| 124 | solve: (a: number, b: number) => number | ||
| 125 | ): ArithmeticProblem { | ||
| 126 | const { a, b } = operandsFor(config, rng); | ||
| 127 | return makeProblem(config, `${a} ${symbol} ${b}`, String(solve(a, b)), "integer"); | ||
| 128 | } | ||
| 129 | |||
| 130 | function divisionProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { | ||
| 131 | const [minDigits, maxDigits] = digitRange(config); | ||
| 132 | const divisor = randDigits(rng, minDigits, Math.max(minDigits, maxDigits - 1)); | ||
| 133 | const quotient = randDigits(rng, minDigits, maxDigits); | ||
| 134 | const clean = config.mode !== "custom" || config.customConfig?.cleanDivision !== false; | ||
| 135 | |||
| 136 | if (clean || config.star <= 3) { | ||
| 137 | return makeProblem(config, `${divisor * quotient} / ${divisor}`, String(quotient), "integer"); | ||
| 138 | } | ||
| 139 | |||
| 140 | const dividend = divisor * quotient + randInt(rng, 1, divisor - 1); | ||
| 141 | return makeProblem(config, `${dividend} / ${divisor}`, reduceFraction(dividend, divisor), "fraction", { | ||
| 142 | acceptsFractions: true, | ||
| 143 | acceptsDecimals: true, | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | |||
| 147 | function mixedProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { | ||
| 148 | const op = pick(rng, ["addition", "subtraction", "multiplication", "division"] as const); | ||
| 149 | return generateArithmeticProblem({ ...config, category: op }, rng); | ||
| 150 | } | ||
| 151 | |||
| 152 | function rootsPowersProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { | ||
| 153 | const submode = config.submode ?? "square"; | ||
| 154 | const star = config.star; | ||
| 155 | const maxBase = submode === "cube" ? 5 + star * 5 : 10 + star * 18; | ||
| 156 | const base = randInt(rng, Math.max(2, star), maxBase); | ||
| 157 | |||
| 158 | if (submode === "sqrt") { | ||
| 159 | return makeProblem( | ||
| 160 | { ...config, submode }, | ||
| 161 | `sqrt(${base * base})`, | ||
| 162 | String(base), | ||
| 163 | "integer" | ||
| 164 | ); | ||
| 165 | } | ||
| 166 | |||
| 167 | const power = submode === "cube" ? 3 : 2; | ||
| 168 | return makeProblem( | ||
| 169 | { ...config, submode }, | ||
| 170 | `${base}^${power}`, | ||
| 171 | String(Math.pow(base, power)), | ||
| 172 | "integer" | ||
| 173 | ); | ||
| 174 | } | ||
| 175 | |||
| 176 | function percentProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { | ||
| 177 | const rates = [5, 10, 12.5, 15, 20, 25, 30, 40, 50, 75]; | ||
| 178 | const rate = config.star >= 4 ? randInt(rng, 2, 95) : pick(rng, rates); | ||
| 179 | const base = randInt(rng, 2, 20 + config.star * 30) * 4; | ||
| 180 | const answer = (base * rate) / 100; | ||
| 181 | |||
| 182 | return makeProblem(config, `${formatNumber(rate)}% of ${base}`, formatNumber(answer), "decimal", { | ||
| 183 | acceptsFractions: true, | ||
| 184 | acceptsDecimals: true, | ||
| 185 | }); | ||
| 186 | } | ||
| 187 | |||
| 188 | function binaryModeProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { | ||
| 189 | const max = Math.pow(2, 3 + config.star) - 1; | ||
| 190 | const a = randInt(rng, 1, max); | ||
| 191 | const b = randInt(rng, 1, max); | ||
| 192 | const op = pick(rng, ["+", "-", "x"] as const); | ||
| 193 | const left = op === "-" ? Math.max(a, b) : a; | ||
| 194 | const right = op === "-" ? Math.min(a, b) : b; | ||
| 195 | const answer = op === "+" ? left + right : op === "-" ? left - right : left * right; | ||
| 196 | |||
| 197 | return makeProblem( | ||
| 198 | config, | ||
| 199 | `${left.toString(2)} ${op} ${right.toString(2)}`, | ||
| 200 | answer.toString(2), | ||
| 201 | "binary" | ||
| 202 | ); | ||
| 203 | } | ||
| 204 | |||
| 205 | function bigMultiplyProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { | ||
| 206 | const magnitude = config.magnitude ?? "thousands"; | ||
| 207 | const [min, max] = BIG_RANGES[magnitude]; | ||
| 208 | const a = randInt(rng, min, max); | ||
| 209 | const b = randDigits(rng, Math.max(1, config.star - 1), Math.min(3, config.star)); | ||
| 210 | |||
| 211 | return makeProblem(config, `${a} x ${b}`, String(a * b), "integer", { magnitude }); | ||
| 212 | } | ||
| 213 | |||
| 214 | function bigDivideProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { | ||
| 215 | const magnitude = config.magnitude ?? "thousands"; | ||
| 216 | const [min, max] = BIG_RANGES[magnitude]; | ||
| 217 | const quotient = randInt(rng, min, max); | ||
| 218 | const divisor = randDigits(rng, 1, Math.min(3, config.star)); | ||
| 219 | |||
| 220 | return makeProblem(config, `${quotient * divisor} / ${divisor}`, String(quotient), "integer", { | ||
| 221 | magnitude, | ||
| 222 | }); | ||
| 223 | } | ||
| 224 | |||
| 225 | function bigPercentProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { | ||
| 226 | const magnitude = config.magnitude ?? "thousands"; | ||
| 227 | const [min, max] = BIG_RANGES[magnitude]; | ||
| 228 | const base = randInt(rng, min, max); | ||
| 229 | const rate = config.star <= 2 ? pick(rng, [1, 5, 10, 20, 25, 50]) : randInt(rng, 2, 35); | ||
| 230 | const answer = (base * rate) / 100; | ||
| 231 | |||
| 232 | return makeProblem(config, `${rate}% of ${base}`, formatNumber(answer), "decimal", { | ||
| 233 | magnitude, | ||
| 234 | acceptsFractions: true, | ||
| 235 | acceptsDecimals: true, | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | |||
| 239 | function operandsFor(config: ArithmeticSessionConfig, rng: Rng) { | ||
| 240 | const [minDigits, maxDigits] = digitRange(config); | ||
| 241 | let a = randDigits(rng, minDigits, maxDigits); | ||
| 242 | let b = randDigits(rng, minDigits, maxDigits); | ||
| 243 | |||
| 244 | if (config.mode === "custom" && config.customConfig?.allowNegatives) { | ||
| 245 | a *= rng.next() < 0.3 ? -1 : 1; | ||
| 246 | b *= rng.next() < 0.3 ? -1 : 1; | ||
| 247 | } | ||
| 248 | |||
| 249 | if (config.category === "subtraction" && config.mode !== "custom" && b > a) { | ||
| 250 | [a, b] = [b, a]; | ||
| 251 | } | ||
| 252 | |||
| 253 | return { a, b }; | ||
| 254 | } | ||
| 255 | |||
| 256 | function digitRange(config: ArithmeticSessionConfig): [number, number] { | ||
| 257 | if (config.mode === "custom" && config.customConfig) { | ||
| 258 | return [ | ||
| 259 | clamp(Math.floor(config.customConfig.minDigits), 1, 9), | ||
| 260 | clamp(Math.floor(config.customConfig.maxDigits), 1, 9), | ||
| 261 | ]; | ||
| 262 | } | ||
| 263 | |||
| 264 | return STAR_DIGITS[config.star]; | ||
| 265 | } | ||
| 266 | |||
| 267 | function makeProblem( | ||
| 268 | config: ArithmeticSessionConfig, | ||
| 269 | expression: string, | ||
| 270 | canonicalAnswer: string, | ||
| 271 | answerFormat: AnswerFormat, | ||
| 272 | options: Record<string, unknown> = {} | ||
| 273 | ): ArithmeticProblem { | ||
| 274 | return { | ||
| 275 | id: crypto.randomUUID(), | ||
| 276 | category: config.category, | ||
| 277 | submode: config.submode, | ||
| 278 | star: config.star, | ||
| 279 | expression, | ||
| 280 | canonicalAnswer, | ||
| 281 | answerFormat, | ||
| 282 | acceptsFractions: Boolean(options.acceptsFractions) || answerFormat === "fraction", | ||
| 283 | acceptsDecimals: Boolean(options.acceptsDecimals) || answerFormat === "decimal", | ||
| 284 | metadata: options, | ||
| 285 | }; | ||
| 286 | } | ||
| 287 | |||
| 288 | function randDigits(rng: Rng, minDigits: number, maxDigits: number): number { | ||
| 289 | const loDigits = Math.min(minDigits, maxDigits); | ||
| 290 | const hiDigits = Math.max(minDigits, maxDigits); | ||
| 291 | const digits = randInt(rng, loDigits, hiDigits); | ||
| 292 | const min = digits === 1 ? 1 : Math.pow(10, digits - 1); | ||
| 293 | const max = Math.pow(10, digits) - 1; | ||
| 294 | return randInt(rng, min, max); | ||
| 295 | } | ||
| 296 | |||
| 297 | function randInt(rng: Rng, min: number, max: number): number { | ||
| 298 | return Math.floor(rng.next() * (max - min + 1)) + min; | ||
| 299 | } | ||
| 300 | |||
| 301 | function pick<T>(rng: Rng, items: readonly T[]): T { | ||
| 302 | return items[Math.floor(rng.next() * items.length)]!; | ||
| 303 | } | ||
| 304 | |||
| 305 | function clamp(value: number, min: number, max: number): number { | ||
| 306 | return Math.min(max, Math.max(min, value)); | ||
| 307 | } | ||
| 308 | |||
| 309 | function formatNumber(value: number): string { | ||
| 310 | return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6))); | ||
| 311 | } | ||
| 312 | |||
| 313 | type Rational = { | ||
| 314 | num: number; | ||
| 315 | den: number; | ||
| 316 | }; | ||
| 317 | |||
| 318 | function parseRational(input: string): Rational | null { | ||
| 319 | const value = input.trim().replace(/\s+/g, ""); | ||
| 320 | |||
| 321 | if (!value) { | ||
| 322 | return null; | ||
| 323 | } | ||
| 324 | |||
| 325 | if (/^-?\d+\/-?\d+$/.test(value)) { | ||
| 326 | const [rawNum, rawDen] = value.split("/"); | ||
| 327 | const den = Number(rawDen); | ||
| 328 | if (den === 0) { | ||
| 329 | return null; | ||
| 330 | } | ||
| 331 | return normalize({ num: Number(rawNum), den }); | ||
| 332 | } | ||
| 333 | |||
| 334 | if (/^-?(?:\d+|\d*\.\d+)$/.test(value)) { | ||
| 335 | const negative = value.startsWith("-"); | ||
| 336 | const unsigned = negative ? value.slice(1) : value; | ||
| 337 | const [whole, decimal = ""] = unsigned.split("."); | ||
| 338 | const den = Math.pow(10, decimal.length); | ||
| 339 | const num = Number((whole || "0") + decimal) * (negative ? -1 : 1); | ||
| 340 | return normalize({ num, den }); | ||
| 341 | } | ||
| 342 | |||
| 343 | return null; | ||
| 344 | } | ||
| 345 | |||
| 346 | function reduceFraction(num: number, den: number): string { | ||
| 347 | const rational = normalize({ num, den }); | ||
| 348 | return rational.den === 1 ? String(rational.num) : `${rational.num}/${rational.den}`; | ||
| 349 | } | ||
| 350 | |||
| 351 | function normalize(rational: Rational): Rational { | ||
| 352 | let { num, den } = rational; | ||
| 353 | |||
| 354 | if (den < 0) { | ||
| 355 | num = -num; | ||
| 356 | den = -den; | ||
| 357 | } | ||
| 358 | |||
| 359 | const divisor = gcd(abs(num), den); | ||
| 360 | return { | ||
| 361 | num: num / divisor, | ||
| 362 | den: den / divisor, | ||
| 363 | }; | ||
| 364 | } | ||
| 365 | |||
| 366 | function gcd(a: number, b: number): number { | ||
| 367 | while (b !== 0) { | ||
| 368 | [a, b] = [b, a % b]; | ||
| 369 | } | ||
| 370 | return a || 1; | ||
| 371 | } | ||
| 372 | |||
| 373 | function abs(value: number): number { | ||
| 374 | return value < 0 ? -value : value; | ||
| 375 | } | ||
| 376 | |||
| 377 | export const arithmeticCategories: Array<{ | ||
| 378 | id: ArithmeticCategory; | ||
| 379 | label: string; | ||
| 380 | description: string; | ||
| 381 | }> = [ | ||
| 382 | { id: "addition", label: "Addition", description: "Fast integer sums" }, | ||
| 383 | { id: "subtraction", label: "Subtraction", description: "Positive and signed differences" }, | ||
| 384 | { id: "multiplication", label: "Multiplication", description: "Tables through three digits" }, | ||
| 385 | { id: "division", label: "Division", description: "Clean division, then fractions" }, | ||
| 386 | { id: "mixed", label: "Mixed ops", description: "Rotating arithmetic operators" }, | ||
| 387 | { id: "roots-powers", label: "Roots and powers", description: "Square roots, squares, cubes" }, | ||
| 388 | { id: "percents", label: "Percents", description: "Everyday percent calculations" }, | ||
| 389 | { id: "binary", label: "Binary", description: "Base-two arithmetic" }, | ||
| 390 | { id: "big-multiply", label: "Big multiply", description: "Large numbers by smaller factors" }, | ||
| 391 | { id: "big-divide", label: "Big divide", description: "Large clean quotients" }, | ||
| 392 | { id: "big-percents", label: "Big percents", description: "Percent work on large quantities" }, | ||
| 393 | ]; | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | export * from "./generator"; | ||
| 2 | export * from "./types"; | ||
| @@ -0,0 +1,64 @@ | |||
| 1 | export type ArithmeticCategory = | ||
| 2 | | "addition" | ||
| 3 | | "subtraction" | ||
| 4 | | "multiplication" | ||
| 5 | | "division" | ||
| 6 | | "mixed" | ||
| 7 | | "roots-powers" | ||
| 8 | | "percents" | ||
| 9 | | "binary" | ||
| 10 | | "big-multiply" | ||
| 11 | | "big-divide" | ||
| 12 | | "big-percents"; | ||
| 13 | |||
| 14 | export type ArithmeticSubmode = "sqrt" | "square" | "cube"; | ||
| 15 | export type ArithmeticStar = 1 | 2 | 3 | 4 | 5; | ||
| 16 | export type ArithmeticRunMode = "untimed" | "30s" | "60s" | "120s" | "300s"; | ||
| 17 | export type BigMagnitude = "thousands" | "millions" | "billions"; | ||
| 18 | export type AnswerFormat = "integer" | "decimal" | "fraction" | "binary"; | ||
| 19 | |||
| 20 | export type ArithmeticCustomConfig = { | ||
| 21 | operations: Array<"addition" | "subtraction" | "multiplication" | "division">; | ||
| 22 | minDigits: number; | ||
| 23 | maxDigits: number; | ||
| 24 | allowNegatives: boolean; | ||
| 25 | allowDecimals: boolean; | ||
| 26 | allowFractions: boolean; | ||
| 27 | cleanDivision: boolean; | ||
| 28 | percentMode: "part" | "rate" | "base"; | ||
| 29 | }; | ||
| 30 | |||
| 31 | export type ArithmeticSessionConfig = { | ||
| 32 | mode: "standard" | "custom"; | ||
| 33 | category: ArithmeticCategory; | ||
| 34 | submode?: ArithmeticSubmode; | ||
| 35 | star: ArithmeticStar; | ||
| 36 | runMode: ArithmeticRunMode; | ||
| 37 | magnitude?: BigMagnitude; | ||
| 38 | customConfig?: ArithmeticCustomConfig; | ||
| 39 | }; | ||
| 40 | |||
| 41 | export type ArithmeticProblem = { | ||
| 42 | id: string; | ||
| 43 | category: ArithmeticCategory; | ||
| 44 | submode?: ArithmeticSubmode; | ||
| 45 | star: ArithmeticStar; | ||
| 46 | expression: string; | ||
| 47 | canonicalAnswer: string; | ||
| 48 | answerFormat: AnswerFormat; | ||
| 49 | acceptsFractions: boolean; | ||
| 50 | acceptsDecimals: boolean; | ||
| 51 | metadata: Record<string, unknown>; | ||
| 52 | }; | ||
| 53 | |||
| 54 | export type ArithmeticAttemptResult = { | ||
| 55 | problem: ArithmeticProblem; | ||
| 56 | userAnswer: string; | ||
| 57 | correct: boolean; | ||
| 58 | elapsedMs: number; | ||
| 59 | scoreDelta: number; | ||
| 60 | }; | ||
| 61 | |||
| 62 | export type Rng = { | ||
| 63 | next(): number; | ||
| 64 | }; | ||
| @@ -1,12 +1,15 @@ | |||
| 1 | import "server-only"; | 1 | import "server-only"; |
| 2 | import { withAuth } from "@workos-inc/authkit-nextjs"; | 2 | import { auth } from "@clerk/nextjs/server"; |
| 3 | 3 | ||
| 4 | export async function getSession() { | 4 | export async function getSession() { |
| 5 | const session = await withAuth(); | 5 | return auth(); |
| 6 | return session; | ||
| 7 | } | 6 | } |
| 8 | 7 | ||
| 9 | export async function requireSession() { | 8 | export async function requireSession() { |
| 10 | const session = await withAuth({ ensureSignedIn: true }); | 9 | const session = await auth(); |
| 10 | if (!session.isAuthenticated) { | ||
| 11 | throw new Error("Unauthorized"); | ||
| 12 | } | ||
| 13 | |||
| 11 | return session; | 14 | return session; |
| 12 | } | 15 | } |
| @@ -0,0 +1,76 @@ | |||
| 1 | import "server-only"; | ||
| 2 | import { eq } from "drizzle-orm"; | ||
| 3 | import { currentUser } from "@clerk/nextjs/server"; | ||
| 4 | import { db } from "@/lib/db"; | ||
| 5 | import { users } from "@/lib/db/schema"; | ||
| 6 | |||
| 7 | type ClerkUser = NonNullable<Awaited<ReturnType<typeof currentUser>>>; | ||
| 8 | |||
| 9 | export async function getCurrentLocalUser() { | ||
| 10 | const clerkUser = await currentUser(); | ||
| 11 | if (!clerkUser) { | ||
| 12 | return null; | ||
| 13 | } | ||
| 14 | |||
| 15 | return ensureLocalUser(clerkUser); | ||
| 16 | } | ||
| 17 | |||
| 18 | export async function ensureLocalUser(clerkUser: ClerkUser) { | ||
| 19 | const existing = await db.query.users.findFirst({ | ||
| 20 | where: eq(users.clerkId, clerkUser.id), | ||
| 21 | }); | ||
| 22 | |||
| 23 | const email = primaryEmail(clerkUser); | ||
| 24 | const displayName = | ||
| 25 | clerkUser.fullName || | ||
| 26 | [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" "); | ||
| 27 | const username = usernameFromClerk(clerkUser, email); | ||
| 28 | |||
| 29 | if (existing) { | ||
| 30 | const [updated] = await db | ||
| 31 | .update(users) | ||
| 32 | .set({ | ||
| 33 | displayName: displayName || existing.displayName, | ||
| 34 | email, | ||
| 35 | avatarUrl: clerkUser.imageUrl, | ||
| 36 | lastActiveAt: new Date(), | ||
| 37 | updatedAt: new Date(), | ||
| 38 | }) | ||
| 39 | .where(eq(users.id, existing.id)) | ||
| 40 | .returning(); | ||
| 41 | |||
| 42 | return updated ?? existing; | ||
| 43 | } | ||
| 44 | |||
| 45 | const [created] = await db | ||
| 46 | .insert(users) | ||
| 47 | .values({ | ||
| 48 | clerkId: clerkUser.id, | ||
| 49 | username, | ||
| 50 | displayName: displayName || username, | ||
| 51 | email, | ||
| 52 | avatarUrl: clerkUser.imageUrl, | ||
| 53 | lastActiveAt: new Date(), | ||
| 54 | }) | ||
| 55 | .returning(); | ||
| 56 | |||
| 57 | return created; | ||
| 58 | } | ||
| 59 | |||
| 60 | function primaryEmail(clerkUser: ClerkUser): string { | ||
| 61 | return ( | ||
| 62 | clerkUser.primaryEmailAddress?.emailAddress || | ||
| 63 | clerkUser.emailAddresses[0]?.emailAddress || | ||
| 64 | `${clerkUser.id}@users.sumtex.local` | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | |||
| 68 | function usernameFromClerk(clerkUser: ClerkUser, email: string): string { | ||
| 69 | const base = (clerkUser.username || email.split("@")[0] || "user") | ||
| 70 | .split("@")[0] | ||
| 71 | .toLowerCase() | ||
| 72 | .replace(/[^a-z0-9_]/g, "") | ||
| 73 | .slice(0, 28); | ||
| 74 | const suffix = clerkUser.id.replace(/[^a-zA-Z0-9]/g, "").slice(-8).toLowerCase(); | ||
| 75 | return `${base || "user"}${suffix}`; | ||
| 76 | } | ||
| @@ -0,0 +1,41 @@ | |||
| 1 | CREATE TABLE IF NOT EXISTS "arithmetic_sessions" ( | ||
| 2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | ||
| 3 | "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE cascade, | ||
| 4 | "mode" varchar(20) NOT NULL, | ||
| 5 | "category" varchar(40) NOT NULL, | ||
| 6 | "submode" varchar(20), | ||
| 7 | "star" integer, | ||
| 8 | "custom_config" jsonb, | ||
| 9 | "seed" integer NOT NULL, | ||
| 10 | "duration_seconds" integer, | ||
| 11 | "score" integer DEFAULT 0 NOT NULL, | ||
| 12 | "total_attempts" integer DEFAULT 0 NOT NULL, | ||
| 13 | "correct_attempts" integer DEFAULT 0 NOT NULL, | ||
| 14 | "started_at" timestamp with time zone DEFAULT now() NOT NULL, | ||
| 15 | "ended_at" timestamp with time zone, | ||
| 16 | "created_at" timestamp with time zone DEFAULT now() NOT NULL | ||
| 17 | ); | ||
| 18 | |||
| 19 | CREATE TABLE IF NOT EXISTS "arithmetic_attempts" ( | ||
| 20 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | ||
| 21 | "session_id" uuid NOT NULL REFERENCES "arithmetic_sessions"("id") ON DELETE cascade, | ||
| 22 | "generated_prompt" jsonb NOT NULL, | ||
| 23 | "display_expression" text NOT NULL, | ||
| 24 | "canonical_answer" varchar(120) NOT NULL, | ||
| 25 | "user_answer" varchar(120) NOT NULL, | ||
| 26 | "correct" boolean NOT NULL, | ||
| 27 | "elapsed_ms" integer NOT NULL, | ||
| 28 | "score_delta" integer DEFAULT 0 NOT NULL, | ||
| 29 | "category" varchar(40) NOT NULL, | ||
| 30 | "submode" varchar(20), | ||
| 31 | "star" integer, | ||
| 32 | "metadata" jsonb, | ||
| 33 | "created_at" timestamp with time zone DEFAULT now() NOT NULL | ||
| 34 | ); | ||
| 35 | |||
| 36 | CREATE INDEX IF NOT EXISTS "arithmetic_sessions_user_idx" ON "arithmetic_sessions" ("user_id"); | ||
| 37 | CREATE INDEX IF NOT EXISTS "arithmetic_sessions_category_idx" ON "arithmetic_sessions" ("category"); | ||
| 38 | CREATE INDEX IF NOT EXISTS "arithmetic_sessions_started_idx" ON "arithmetic_sessions" ("started_at"); | ||
| 39 | CREATE INDEX IF NOT EXISTS "arithmetic_attempts_session_idx" ON "arithmetic_attempts" ("session_id"); | ||
| 40 | CREATE INDEX IF NOT EXISTS "arithmetic_attempts_category_idx" ON "arithmetic_attempts" ("category"); | ||
| 41 | CREATE INDEX IF NOT EXISTS "arithmetic_attempts_created_idx" ON "arithmetic_attempts" ("created_at"); | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "clerk_id" text; | ||
| 2 | |||
| 3 | UPDATE "users" | ||
| 4 | SET "clerk_id" = "workos_id" | ||
| 5 | WHERE "clerk_id" IS NULL | ||
| 6 | AND "workos_id" IS NOT NULL; | ||
| 7 | |||
| 8 | ALTER TABLE "users" ALTER COLUMN "clerk_id" SET NOT NULL; | ||
| 9 | CREATE UNIQUE INDEX IF NOT EXISTS "users_clerk_id_idx" ON "users" ("clerk_id"); | ||
| 10 | DROP INDEX IF EXISTS "users_workos_id_idx"; | ||
| 11 | ALTER TABLE "users" DROP COLUMN IF EXISTS "workos_id"; | ||
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | |||
| 6 | integer, | 6 | integer, |
| 7 | boolean, | 7 | boolean, |
| 8 | timestamp, | 8 | timestamp, |
| 9 | jsonb, | ||
| 9 | uniqueIndex, | 10 | uniqueIndex, |
| 10 | index, | 11 | index, |
| 11 | } from "drizzle-orm/pg-core"; | 12 | } from "drizzle-orm/pg-core"; |
| @@ -19,7 +20,7 @@ export const users = pgTable( · 7 unmodified lines | |||
| 19 | "users", | 20 | "users", |
| 20 | { | 21 | { |
| 21 | id: uuid("id").primaryKey().defaultRandom(), | 22 | id: uuid("id").primaryKey().defaultRandom(), |
| 22 | workosId: text("workos_id").notNull(), | 23 | clerkId: text("clerk_id").notNull(), |
| 23 | username: varchar("username", { length: 40 }).notNull(), | 24 | username: varchar("username", { length: 40 }).notNull(), |
| 24 | displayName: varchar("display_name", { length: 100 }), | 25 | displayName: varchar("display_name", { length: 100 }), |
| 25 | email: varchar("email", { length: 255 }).notNull(), | 26 | email: varchar("email", { length: 255 }).notNull(), |
| @@ -36,7 +37,7 @@ export const users = pgTable( · 10 unmodified lines | |||
| 36 | updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), | 37 | updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), |
| 37 | }, | 38 | }, |
| 38 | (t) => [ | 39 | (t) => [ |
| 39 | uniqueIndex("users_workos_id_idx").on(t.workosId), | 40 | uniqueIndex("users_clerk_id_idx").on(t.clerkId), |
| 40 | uniqueIndex("users_username_idx").on(t.username), | 41 | uniqueIndex("users_username_idx").on(t.username), |
| 41 | uniqueIndex("users_email_idx").on(t.email), | 42 | uniqueIndex("users_email_idx").on(t.email), |
| 42 | ] | 43 | ] |
| @@ -299,6 +300,65 @@ export const gameSessions = pgTable("game_sessions", { · 256 unmodified lines | |||
| 299 | endedAt: timestamp("ended_at", { withTimezone: true }), | 300 | endedAt: timestamp("ended_at", { withTimezone: true }), |
| 300 | }); | 301 | }); |
| 301 | 302 | ||
| 303 | // ============================================================ | ||
| 304 | // ARITHMETIC | ||
| 305 | // ============================================================ | ||
| 306 | |||
| 307 | export const arithmeticSessions = pgTable( | ||
| 308 | "arithmetic_sessions", | ||
| 309 | { | ||
| 310 | id: uuid("id").primaryKey().defaultRandom(), | ||
| 311 | userId: uuid("user_id") | ||
| 312 | .notNull() | ||
| 313 | .references(() => users.id, { onDelete: "cascade" }), | ||
| 314 | mode: varchar("mode", { length: 20 }).notNull(), | ||
| 315 | category: varchar("category", { length: 40 }).notNull(), | ||
| 316 | submode: varchar("submode", { length: 20 }), | ||
| 317 | star: integer("star"), | ||
| 318 | customConfig: jsonb("custom_config"), | ||
| 319 | seed: integer("seed").notNull(), | ||
| 320 | durationSeconds: integer("duration_seconds"), | ||
| 321 | score: integer("score").notNull().default(0), | ||
| 322 | totalAttempts: integer("total_attempts").notNull().default(0), | ||
| 323 | correctAttempts: integer("correct_attempts").notNull().default(0), | ||
| 324 | startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 325 | endedAt: timestamp("ended_at", { withTimezone: true }), | ||
| 326 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 327 | }, | ||
| 328 | (t) => [ | ||
| 329 | index("arithmetic_sessions_user_idx").on(t.userId), | ||
| 330 | index("arithmetic_sessions_category_idx").on(t.category), | ||
| 331 | index("arithmetic_sessions_started_idx").on(t.startedAt), | ||
| 332 | ] | ||
| 333 | ); | ||
| 334 | |||
| 335 | export const arithmeticAttempts = pgTable( | ||
| 336 | "arithmetic_attempts", | ||
| 337 | { | ||
| 338 | id: uuid("id").primaryKey().defaultRandom(), | ||
| 339 | sessionId: uuid("session_id") | ||
| 340 | .notNull() | ||
| 341 | .references(() => arithmeticSessions.id, { onDelete: "cascade" }), | ||
| 342 | generatedPrompt: jsonb("generated_prompt").notNull(), | ||
| 343 | displayExpression: text("display_expression").notNull(), | ||
| 344 | canonicalAnswer: varchar("canonical_answer", { length: 120 }).notNull(), | ||
| 345 | userAnswer: varchar("user_answer", { length: 120 }).notNull(), | ||
| 346 | correct: boolean("correct").notNull(), | ||
| 347 | elapsedMs: integer("elapsed_ms").notNull(), | ||
| 348 | scoreDelta: integer("score_delta").notNull().default(0), | ||
| 349 | category: varchar("category", { length: 40 }).notNull(), | ||
| 350 | submode: varchar("submode", { length: 20 }), | ||
| 351 | star: integer("star"), | ||
| 352 | metadata: jsonb("metadata"), | ||
| 353 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 354 | }, | ||
| 355 | (t) => [ | ||
| 356 | index("arithmetic_attempts_session_idx").on(t.sessionId), | ||
| 357 | index("arithmetic_attempts_category_idx").on(t.category), | ||
| 358 | index("arithmetic_attempts_created_idx").on(t.createdAt), | ||
| 359 | ] | ||
| 360 | ); | ||
| 361 | |||
| 302 | // ============================================================ | 362 | // ============================================================ |
| 303 | // LEADERBOARD | 363 | // LEADERBOARD |
| 304 | // ============================================================ | 364 | // ============================================================ |
| @@ -403,6 +463,7 @@ export const userApiKeys = pgTable( · 98 unmodified lines | |||
| 403 | export const usersRelations = relations(users, ({ many }) => ({ | 463 | export const usersRelations = relations(users, ({ many }) => ({ |
| 404 | progress: many(userProgress), | 464 | progress: many(userProgress), |
| 405 | sessions: many(gameSessions), | 465 | sessions: many(gameSessions), |
| 466 | arithmeticSessions: many(arithmeticSessions), | ||
| 406 | submittedProblems: many(problems), | 467 | submittedProblems: many(problems), |
| 407 | apiKeys: many(userApiKeys), | 468 | apiKeys: many(userApiKeys), |
| 408 | threads: many(forumThreads), | 469 | threads: many(forumThreads), |
| @@ -502,6 +563,24 @@ export const gameSessionsRelations = relations(gameSessions, ({ one }) => ({ · 93 unmodified lines | |||
| 502 | }), | 563 | }), |
| 503 | })); | 564 | })); |
| 504 | 565 | ||
| 566 | export const arithmeticSessionsRelations = relations( | ||
| 567 | arithmeticSessions, | ||
| 568 | ({ one, many }) => ({ | ||
| 569 | user: one(users, { | ||
| 570 | fields: [arithmeticSessions.userId], | ||
| 571 | references: [users.id], | ||
| 572 | }), | ||
| 573 | attempts: many(arithmeticAttempts), | ||
| 574 | }) | ||
| 575 | ); | ||
| 576 | |||
| 577 | export const arithmeticAttemptsRelations = relations(arithmeticAttempts, ({ one }) => ({ | ||
| 578 | session: one(arithmeticSessions, { | ||
| 579 | fields: [arithmeticAttempts.sessionId], | ||
| 580 | references: [arithmeticSessions.id], | ||
| 581 | }), | ||
| 582 | })); | ||
| 583 | |||
| 505 | export const forumThreadsRelations = relations(forumThreads, ({ one, many }) => ({ | 584 | export const forumThreadsRelations = relations(forumThreads, ({ one, many }) => ({ |
| 506 | category: one(forumCategories, { | 585 | category: one(forumCategories, { |
| 507 | fields: [forumThreads.categoryId], | 586 | fields: [forumThreads.categoryId], |
| @@ -0,0 +1,220 @@ | |||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { MathDisplay } from "@/components/math/mathJax"; | ||
| 3 | |||
| 4 | export type Guide = { | ||
| 5 | slug: string; | ||
| 6 | title: string; | ||
| 7 | dek: string; | ||
| 8 | author: string; | ||
| 9 | source: "Platform" | "User"; | ||
| 10 | level: string; | ||
| 11 | readTime: string; | ||
| 12 | tags: string[]; | ||
| 13 | updatedAt: string; | ||
| 14 | sections: Array<{ | ||
| 15 | heading: string; | ||
| 16 | body: ReactNode; | ||
| 17 | }>; | ||
| 18 | }; | ||
| 19 | |||
| 20 | export const guides: Guide[] = [ | ||
| 21 | { | ||
| 22 | slug: "algebraic-factoring-field-guide", | ||
| 23 | title: "A field guide to algebraic factoring", | ||
| 24 | dek: "Recognize the common shapes behind school algebra before the symbols get noisy.", | ||
| 25 | author: "@krisyotam", | ||
| 26 | source: "Platform", | ||
| 27 | level: "Algebra", | ||
| 28 | readTime: "9 min", | ||
| 29 | tags: ["algebra", "factoring", "patterns"], | ||
| 30 | updatedAt: "July 14, 2026", | ||
| 31 | sections: [ | ||
| 32 | { | ||
| 33 | heading: "The point", | ||
| 34 | body: ( | ||
| 35 | <> | ||
| 36 | <p> | ||
| 37 | Factoring is pattern recognition with a proof attached. You are rewriting one expression as a product so that roots, signs, and cancellations become visible. | ||
| 38 | </p> | ||
| 39 | <MathDisplay latex="x^2 + 5x + 6 = (x + 2)(x + 3)" /> | ||
| 40 | <p> | ||
| 41 | The right side is not a trick. It expands back into the left side, so the rewrite preserves the expression while revealing its structure. | ||
| 42 | </p> | ||
| 43 | </> | ||
| 44 | ), | ||
| 45 | }, | ||
| 46 | { | ||
| 47 | heading: "Start with the greatest common factor", | ||
| 48 | body: ( | ||
| 49 | <> | ||
| 50 | <p> | ||
| 51 | Before looking for a famous pattern, remove anything shared by every term. This reduces the work and keeps later steps smaller. | ||
| 52 | </p> | ||
| 53 | <MathDisplay latex="12x^3 - 18x^2 = 6x^2(2x - 3)" /> | ||
| 54 | </> | ||
| 55 | ), | ||
| 56 | }, | ||
| 57 | { | ||
| 58 | heading: "Quadratics by sum and product", | ||
| 59 | body: ( | ||
| 60 | <> | ||
| 61 | <p> | ||
| 62 | For a monic quadratic, find two numbers whose product is the constant term and whose sum is the linear coefficient. | ||
| 63 | </p> | ||
| 64 | <MathDisplay latex="x^2 + bx + c = (x + m)(x + n), \\quad m+n=b, \\quad mn=c" /> | ||
| 65 | <p> | ||
| 66 | This test is fast because it turns a symbolic problem into a short integer search. | ||
| 67 | </p> | ||
| 68 | </> | ||
| 69 | ), | ||
| 70 | }, | ||
| 71 | { | ||
| 72 | heading: "Difference of squares", | ||
| 73 | body: ( | ||
| 74 | <> | ||
| 75 | <p> | ||
| 76 | Two square terms separated by subtraction factor into conjugates. The middle terms cancel during expansion. | ||
| 77 | </p> | ||
| 78 | <MathDisplay latex="a^2 - b^2 = (a-b)(a+b)" /> | ||
| 79 | <p> | ||
| 80 | Typical examples include $x^2 - 49$, $9y^2 - 4$, and $16m^4 - n^2$. | ||
| 81 | </p> | ||
| 82 | </> | ||
| 83 | ), | ||
| 84 | }, | ||
| 85 | ], | ||
| 86 | }, | ||
| 87 | { | ||
| 88 | slug: "limits-without-mysticism", | ||
| 89 | title: "Limits without mysticism", | ||
| 90 | dek: "A first calculus guide for reading limit notation as a claim about control.", | ||
| 91 | author: "@krisyotam", | ||
| 92 | source: "User", | ||
| 93 | level: "Calculus", | ||
| 94 | readTime: "11 min", | ||
| 95 | tags: ["calculus", "limits", "epsilon delta"], | ||
| 96 | updatedAt: "July 14, 2026", | ||
| 97 | sections: [ | ||
| 98 | { | ||
| 99 | heading: "What a limit says", | ||
| 100 | body: ( | ||
| 101 | <> | ||
| 102 | <p> | ||
| 103 | A limit is not asking what happens at a single point. It asks what values can be forced near a target by choosing inputs close enough to a point. | ||
| 104 | </p> | ||
| 105 | <MathDisplay latex="\\lim_{x \\to a} f(x) = L" /> | ||
| 106 | <p> | ||
| 107 | Read this as: values of $f(x)$ can be made arbitrarily close to $L$ by taking $x$ close enough to $a$. | ||
| 108 | </p> | ||
| 109 | </> | ||
| 110 | ), | ||
| 111 | }, | ||
| 112 | { | ||
| 113 | heading: "The epsilon delta form", | ||
| 114 | body: ( | ||
| 115 | <> | ||
| 116 | <p> | ||
| 117 | The formal definition turns closeness into two tolerances. Epsilon measures output error. Delta measures input distance. | ||
| 118 | </p> | ||
| 119 | <MathDisplay latex="0 < |x-a| < \\delta \\implies |f(x)-L| < \\varepsilon" /> | ||
| 120 | <p> | ||
| 121 | The game is to pick a delta that works for any requested epsilon. | ||
| 122 | </p> | ||
| 123 | </> | ||
| 124 | ), | ||
| 125 | }, | ||
| 126 | { | ||
| 127 | heading: "A linear example", | ||
| 128 | body: ( | ||
| 129 | <> | ||
| 130 | <p> | ||
| 131 | For $f(x)=3x+1$ at $x=2$, the expected limit is $7$. The error simplifies cleanly. | ||
| 132 | </p> | ||
| 133 | <MathDisplay latex="|(3x+1)-7| = |3x-6| = 3|x-2|" /> | ||
| 134 | <p> | ||
| 135 | Choosing $\delta = \varepsilon/3$ makes the output error smaller than epsilon. | ||
| 136 | </p> | ||
| 137 | </> | ||
| 138 | ), | ||
| 139 | }, | ||
| 140 | { | ||
| 141 | heading: "Why this matters", | ||
| 142 | body: ( | ||
| 143 | <> | ||
| 144 | <p> | ||
| 145 | Derivatives, continuity, and integrals all depend on limits. The notation is compact, but the underlying idea is consistent: control output behavior by controlling input behavior. | ||
| 146 | </p> | ||
| 147 | </> | ||
| 148 | ), | ||
| 149 | }, | ||
| 150 | ], | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | slug: "complex-numbers-as-geometry", | ||
| 154 | title: "Complex numbers as geometry", | ||
| 155 | dek: "Use the plane, not just rules, to understand multiplication by complex numbers.", | ||
| 156 | author: "@krisyotam", | ||
| 157 | source: "Platform", | ||
| 158 | level: "Complex Analysis", | ||
| 159 | readTime: "8 min", | ||
| 160 | tags: ["complex numbers", "geometry", "analysis"], | ||
| 161 | updatedAt: "July 14, 2026", | ||
| 162 | sections: [ | ||
| 163 | { | ||
| 164 | heading: "The plane model", | ||
| 165 | body: ( | ||
| 166 | <> | ||
| 167 | <p> | ||
| 168 | A complex number has a real part and an imaginary part, so it can be plotted as a point or vector in the plane. | ||
| 169 | </p> | ||
| 170 | <MathDisplay latex="z = a + bi \\quad \\leftrightarrow \\quad (a,b)" /> | ||
| 171 | </> | ||
| 172 | ), | ||
| 173 | }, | ||
| 174 | { | ||
| 175 | heading: "Modulus and argument", | ||
| 176 | body: ( | ||
| 177 | <> | ||
| 178 | <p> | ||
| 179 | The modulus is distance from the origin. The argument is angle from the positive real axis. | ||
| 180 | </p> | ||
| 181 | <MathDisplay latex="|z| = \\sqrt{a^2+b^2}, \\quad \\arg(z)=\\theta" /> | ||
| 182 | <p> | ||
| 183 | This makes polar form natural. | ||
| 184 | </p> | ||
| 185 | <MathDisplay latex="z = r(\\cos \\theta + i\\sin \\theta)" /> | ||
| 186 | </> | ||
| 187 | ), | ||
| 188 | }, | ||
| 189 | { | ||
| 190 | heading: "Multiplication rotates and scales", | ||
| 191 | body: ( | ||
| 192 | <> | ||
| 193 | <p> | ||
| 194 | Multiplying complex numbers multiplies their lengths and adds their angles. | ||
| 195 | </p> | ||
| 196 | <MathDisplay latex="r_1 e^{i\\theta_1} \\cdot r_2 e^{i\\theta_2} = r_1r_2 e^{i(\\theta_1+\\theta_2)}" /> | ||
| 197 | <p> | ||
| 198 | This is the reason $i$ behaves like a quarter-turn: multiplying by $i$ rotates by $90^\\circ$. | ||
| 199 | </p> | ||
| 200 | </> | ||
| 201 | ), | ||
| 202 | }, | ||
| 203 | { | ||
| 204 | heading: "Roots become symmetry", | ||
| 205 | body: ( | ||
| 206 | <> | ||
| 207 | <p> | ||
| 208 | The $n$th roots of a complex number sit evenly around a circle. Algebra becomes a regular polygon. | ||
| 209 | </p> | ||
| 210 | <MathDisplay latex="z_k = r^{1/n} e^{i(\\theta + 2\\pi k)/n}, \\quad k=0,1,\\dots,n-1" /> | ||
| 211 | </> | ||
| 212 | ), | ||
| 213 | }, | ||
| 214 | ], | ||
| 215 | }, | ||
| 216 | ]; | ||
| 217 | |||
| 218 | export function getGuide(slug: string) { | ||
| 219 | return guides.find((guide) => guide.slug === slug); | ||
| 220 | } | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | import { clerkMiddleware } from "@clerk/nextjs/server"; | ||
| 2 | import type { NextFetchEvent, NextRequest } from "next/server"; | ||
| 3 | |||
| 4 | const clerkProxy = clerkMiddleware(); | ||
| 5 | |||
| 6 | export function proxy(request: NextRequest, event: NextFetchEvent) { | ||
| 7 | return clerkProxy(request, event); | ||
| 8 | } | ||
| 9 | |||
| 10 | export const config = { | ||
| 11 | matcher: [ | ||
| 12 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", | ||
| 13 | "/(api|trpc)(.*)", | ||
| 14 | ], | ||
| 15 | }; | ||
| @@ -9,6 +9,8 @@ import type { · 8 unmodified lines | |||
| 9 | collections, | 9 | collections, |
| 10 | userProgress, | 10 | userProgress, |
| 11 | gameSessions, | 11 | gameSessions, |
| 12 | arithmeticSessions, | ||
| 13 | arithmeticAttempts, | ||
| 12 | leaderboardEntries, | 14 | leaderboardEntries, |
| 13 | forumCategories, | 15 | forumCategories, |
| 14 | forumThreads, | 16 | forumThreads, |
| @@ -25,6 +27,8 @@ export type Roadmap = InferSelectModel<typeof roadmaps>; · 10 unmodified lines | |||
| 25 | export type Collection = InferSelectModel<typeof collections>; | 27 | export type Collection = InferSelectModel<typeof collections>; |
| 26 | export type UserProgress = InferSelectModel<typeof userProgress>; | 28 | export type UserProgress = InferSelectModel<typeof userProgress>; |
| 27 | export type GameSession = InferSelectModel<typeof gameSessions>; | 29 | export type GameSession = InferSelectModel<typeof gameSessions>; |
| 30 | export type ArithmeticSession = InferSelectModel<typeof arithmeticSessions>; | ||
| 31 | export type ArithmeticAttempt = InferSelectModel<typeof arithmeticAttempts>; | ||
| 28 | export type LeaderboardEntry = InferSelectModel<typeof leaderboardEntries>; | 32 | export type LeaderboardEntry = InferSelectModel<typeof leaderboardEntries>; |
| 29 | export type ForumCategory = InferSelectModel<typeof forumCategories>; | 33 | export type ForumCategory = InferSelectModel<typeof forumCategories>; |
| 30 | export type ForumThread = InferSelectModel<typeof forumThreads>; | 34 | export type ForumThread = InferSelectModel<typeof forumThreads>; |