Unified · Split
| @@ -34,3 +34,6 @@ yarn-error.log* · 33 unmodified lines | ||
| 34 | 34 | # typescript |
| 35 | 35 | *.tsbuildinfo |
| 36 | 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 | 9 | "version": "3.0.0", |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "dependencies": { |
| 12 | − "@workos-inc/authkit-nextjs": "latest", | |
| 12 | + "@clerk/nextjs": "^7.5.17", | |
| 13 | 13 | "better-react-mathjax": "^2.1.0", |
| 14 | 14 | "drizzle-orm": "^0.44.0", |
| 15 | 15 | "geist": "^1.4.1", |
| @@ -45,6 +45,85 @@ · 29 unmodified lines | ||
| 45 | 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 | 127 | "node_modules/@drizzle-team/brocli": { |
| 49 | 128 | "version": "0.10.2", |
| 50 | 129 | "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", |
| @@ -1650,17 +1729,11 @@ · 1599 unmodified lines | ||
| 1650 | 1729 | "node": ">= 10" |
| 1651 | 1730 | } |
| 1652 | 1731 | }, |
| 1653 | − "node_modules/@sindresorhus/fnv1a": { | |
| 1654 | − "version": "3.1.0", | |
| 1655 | − "resolved": "https://registry.npmjs.org/@sindresorhus/fnv1a/-/fnv1a-3.1.0.tgz", | |
| 1656 | − "integrity": "sha512-KV321z5m/0nuAg83W1dPLy85HpHDk7Sdi4fJbwvacWsEhAh+rZUW4ZfGcXmUIvjZg4ss2bcwNlRhJ7GBEUG08w==", | |
| 1657 | − "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 | − } | |
| 1732 | + "node_modules/@stablelib/base64": { | |
| 1733 | + "version": "1.0.1", | |
| 1734 | + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", | |
| 1735 | + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", | |
| 1736 | + "license": "MIT" | |
| 1664 | 1737 | }, |
| 1665 | 1738 | "node_modules/@swc/helpers": { |
| 1666 | 1739 | "version": "0.5.15", |
| @@ -1954,6 +2027,16 @@ · 287 unmodified lines | ||
| 1954 | 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 | 2040 | "node_modules/@types/node": { |
| 1958 | 2041 | "version": "25.9.1", |
| 1959 | 2042 | "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", |
| @@ -1984,40 +2067,6 @@ · 24 unmodified lines | ||
| 1984 | 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 | 2070 | "node_modules/@xmldom/xmldom": { |
| 2022 | 2071 | "version": "0.9.10", |
| 2023 | 2072 | "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", |
| @@ -2093,15 +2142,6 @@ · 69 unmodified lines | ||
| 2093 | 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 | 2145 | "node_modules/csstype": { |
| 2106 | 2146 | "version": "3.2.3", |
| 2107 | 2147 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", |
| @@ -2109,6 +2149,15 @@ · 1 unmodified line | ||
| 2109 | 2149 | "dev": true, |
| 2110 | 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 | 2161 | "node_modules/detect-libc": { |
| 2113 | 2162 | "version": "2.1.2", |
| 2114 | 2163 | "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", |
| @@ -2325,11 +2374,11 @@ · 210 unmodified lines | ||
| 2325 | 2374 | "node": ">=6" |
| 2326 | 2375 | } |
| 2327 | 2376 | }, |
| 2328 | − "node_modules/eventemitter3": { | |
| 2329 | − "version": "5.0.4", | |
| 2330 | − "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", | |
| 2331 | − "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", | |
| 2332 | − "license": "MIT" | |
| 2377 | + "node_modules/fast-sha256": { | |
| 2378 | + "version": "1.3.0", | |
| 2379 | + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", | |
| 2380 | + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", | |
| 2381 | + "license": "Unlicense" | |
| 2333 | 2382 | }, |
| 2334 | 2383 | "node_modules/fsevents": { |
| 2335 | 2384 | "version": "2.3.3", |
| @@ -2368,6 +2417,12 @@ · 32 unmodified lines | ||
| 2368 | 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 | 2426 | "node_modules/graceful-fs": { |
| 2372 | 2427 | "version": "4.2.11", |
| 2373 | 2428 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", |
| @@ -2375,30 +2430,6 @@ · 1 unmodified line | ||
| 2375 | 2430 | "dev": true, |
| 2376 | 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 | 2433 | "node_modules/jiti": { |
| 2403 | 2434 | "version": "2.7.0", |
| 2404 | 2435 | "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", |
| @@ -2409,13 +2440,13 @@ · 4 unmodified lines | ||
| 2409 | 2440 | "jiti": "lib/jiti-cli.mjs" |
| 2410 | 2441 | } |
| 2411 | 2442 | }, |
| 2412 | − "node_modules/jose": { | |
| 2413 | − "version": "5.10.0", | |
| 2414 | − "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", | |
| 2415 | − "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", | |
| 2443 | + "node_modules/js-cookie": { | |
| 2444 | + "version": "3.0.7", | |
| 2445 | + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", | |
| 2446 | + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", | |
| 2416 | 2447 | "license": "MIT", |
| 2417 | − "funding": { | |
| 2418 | − "url": "https://github.com/sponsors/panva" | |
| 2448 | + "engines": { | |
| 2449 | + "node": ">=20" | |
| 2419 | 2450 | } |
| 2420 | 2451 | }, |
| 2421 | 2452 | "node_modules/lightningcss": { |
| @@ -2825,12 +2856,6 @@ · 403 unmodified lines | ||
| 2825 | 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 | 2859 | "node_modules/picocolors": { |
| 2835 | 2860 | "version": "1.1.1", |
| 2836 | 2861 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", |
| @@ -3024,6 +3049,16 @@ · 187 unmodified lines | ||
| 3024 | 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 | 3062 | "node_modules/styled-jsx": { |
| 3028 | 3063 | "version": "5.1.6", |
| 3029 | 3064 | "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", |
| @@ -3581,7 +3616,7 @@ · 551 unmodified lines | ||
| 3581 | 3616 | "version": "6.0.3", |
| 3582 | 3617 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", |
| 3583 | 3618 | "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", |
| 3584 | − "devOptional": true, | |
| 3619 | + "dev": true, | |
| 3585 | 3620 | "license": "Apache-2.0", |
| 3586 | 3621 | "bin": { |
| 3587 | 3622 | "tsc": "bin/tsc", |
| @@ -3591,12 +3626,6 @@ · 3 unmodified lines | ||
| 3591 | 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 | 3629 | "node_modules/undici-types": { |
| 3601 | 3630 | "version": "7.24.6", |
| 3602 | 3631 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", |
| @@ -3604,20 +3633,6 @@ · 1 unmodified line | ||
| 3604 | 3633 | "dev": true, |
| 3605 | 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 | 3636 | "node_modules/wicked-good-xpath": { |
| 3622 | 3637 | "version": "1.3.0", |
| 3623 | 3638 | "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", |
| @@ -8,6 +8,7 @@ · 7 unmodified lines | ||
| 8 | 8 | "build": "next build", |
| 9 | 9 | "start": "next start", |
| 10 | 10 | "lint": "next lint", |
| 11 | + "test:arithmetic": "tsx scripts/test-arithmetic.ts", | |
| 11 | 12 | "db:generate": "drizzle-kit generate", |
| 12 | 13 | "db:migrate": "tsx scripts/migrate.ts", |
| 13 | 14 | "db:seed": "tsx scripts/seed.ts", |
| @@ -19,7 +20,7 @@ · 5 unmodified lines | ||
| 19 | 20 | }, |
| 20 | 21 | "license": "MIT", |
| 21 | 22 | "dependencies": { |
| 22 | − "@workos-inc/authkit-nextjs": "latest", | |
| 23 | + "@clerk/nextjs": "^7.5.17", | |
| 23 | 24 | "better-react-mathjax": "^2.1.0", |
| 24 | 25 | "drizzle-orm": "^0.44.0", |
| 25 | 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 | 1 | export default function AppLayout({ children }: { children: React.ReactNode }) { |
| 4 | 2 | return ( |
| 5 | 3 | <div |
| 6 | 4 | className="min-h-screen flex flex-col" |
| 7 | 5 | style={{ background: "var(--bg-canvas)" }} |
| 8 | 6 | > |
| 9 | − <Header /> | |
| 10 | 7 | <main className="flex-1">{children}</main> |
| 11 | 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 | 5 | return ( |
| 3 | − <div className="max-w-4xl mx-auto px-6 py-12"> | |
| 4 | − <h1 | |
| 5 | − className="text-2xl font-semibold mb-4" | |
| 6 | − style={{ color: "var(--text-primary)" }} | |
| 7 | − > | |
| 8 | − Textbook Paths | |
| 9 | − </h1> | |
| 10 | − <p style={{ color: "var(--text-secondary)" }}> | |
| 11 | − Work through a textbook from start to finish. Each path contains all | |
| 12 | − problems from a specific text with synopses and page references. | |
| 13 | − </p> | |
| 14 | − </div> | |
| 6 | + <> | |
| 7 | + <style>{guideIndexStyles}</style> | |
| 8 | + <main className="guides-shell"> | |
| 9 | + <div className="guides-frame"> | |
| 10 | + <SurfaceNav variant="light" /> | |
| 11 | + | |
| 12 | + <header className="guides-hero"> | |
| 13 | + <p>SumTex Guides</p> | |
| 14 | + <h1>Guides</h1> | |
| 15 | + <div> | |
| 16 | + Platform and user-authored documents for algebra, calculus, analysis, and proof technique. | |
| 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 | 12 | export default function PlayPage() { |
| 2 | 13 | return ( |
| 3 | − <div className="max-w-4xl mx-auto px-6 py-12"> | |
| 4 | − <h1 | |
| 5 | − className="text-2xl font-semibold mb-4" | |
| 6 | − style={{ color: "var(--text-primary)" }} | |
| 7 | − > | |
| 8 | − Play | |
| 9 | − </h1> | |
| 10 | − <p style={{ color: "var(--text-secondary)" }}> | |
| 11 | − Select a mode to begin solving problems. | |
| 12 | − </p> | |
| 13 | − <div className="grid grid-cols-2 gap-6 mt-8"> | |
| 14 | − <div | |
| 15 | − className="p-6 rounded-lg border cursor-pointer transition-colors" | |
| 16 | − style={{ | |
| 17 | − background: "var(--bg-primary)", | |
| 18 | − borderColor: "var(--border-color)", | |
| 19 | − }} | |
| 20 | − > | |
| 21 | − <h2 | |
| 22 | − className="text-lg font-medium mb-2" | |
| 23 | − style={{ color: "var(--text-primary)" }} | |
| 24 | − > | |
| 25 | − Timed | |
| 26 | − </h2> | |
| 27 | − <p className="text-sm" style={{ color: "var(--text-secondary)" }}> | |
| 28 | − 3 minutes. Solve as many problems as you can. 1.5x XP multiplier. | |
| 29 | − </p> | |
| 30 | − </div> | |
| 31 | − <div | |
| 32 | − className="p-6 rounded-lg border cursor-pointer transition-colors" | |
| 33 | − style={{ | |
| 34 | − background: "var(--bg-primary)", | |
| 35 | − borderColor: "var(--border-color)", | |
| 36 | − }} | |
| 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> | |
| 14 | + <> | |
| 15 | + <style>{playHubStyles}</style> | |
| 16 | + <div className="play-shell"> | |
| 17 | + <div className="play-frame"> | |
| 18 | + <SurfaceNav variant="dark" /> | |
| 19 | + | |
| 20 | + <header className="play-hero"> | |
| 21 | + <div> | |
| 22 | + <p className="play-kicker">SumTex Play</p> | |
| 23 | + <h1>Modes of Arithmetic</h1> | |
| 24 | + <p> | |
| 25 | + Choose the drill type first. Stars, timing, custom settings, and keypad entry stay inside the run page. | |
| 26 | + </p> | |
| 27 | + </div> | |
| 28 | + <a className="play-cta" href="/play/mental-arithmetic?category=addition&star=2"> | |
| 29 | + Start addition | |
| 30 | + </a> | |
| 31 | + </header> | |
| 32 | + | |
| 33 | + <section className="play-grid" aria-label="Arithmetic modes"> | |
| 34 | + {arithmeticModes.map((mode, index) => ( | |
| 35 | + <a key={mode.title} className="play-mode live" href={mode.href}> | |
| 36 | + <div className="play-mode-index">{String(index + 1).padStart(2, "0")}</div> | |
| 37 | + <div className="play-mode-body"> | |
| 38 | + <div className="play-mode-top"> | |
| 39 | + <h2>{mode.title}</h2> | |
| 40 | + <span className="play-status live">{mode.status}</span> | |
| 41 | + </div> | |
| 42 | + <p>{mode.description}</p> | |
| 43 | + <div className="play-mode-detail">{mode.detail}</div> | |
| 44 | + </div> | |
| 45 | + </a> | |
| 46 | + ))} | |
| 47 | + </section> | |
| 47 | 48 | </div> |
| 48 | 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 | 48 | export default function ProblemsPage() { |
| 2 | 49 | return ( |
| 3 | − <div className="max-w-4xl mx-auto px-6 py-12"> | |
| 4 | − <h1 | |
| 5 | − className="text-2xl font-semibold mb-4" | |
| 6 | − style={{ color: "var(--text-primary)" }} | |
| 7 | − > | |
| 8 | − Problems | |
| 9 | − </h1> | |
| 10 | − <p style={{ color: "var(--text-secondary)" }}> | |
| 11 | − Browse problems by subject, difficulty, or textbook. | |
| 12 | − </p> | |
| 13 | − </div> | |
| 50 | + <> | |
| 51 | + <style>{problemHubStyles}</style> | |
| 52 | + <div className="problem-shell"> | |
| 53 | + <div className="problem-frame"> | |
| 54 | + <SurfaceNav variant="dark" /> | |
| 55 | + | |
| 56 | + <header className="problem-hero"> | |
| 57 | + <div> | |
| 58 | + <p className="problem-kicker">SumTex Problems</p> | |
| 59 | + <h1>Choose a subject</h1> | |
| 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 | 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 | 281 | return ( |
| 3 | − <div className="max-w-4xl mx-auto px-6 py-12"> | |
| 4 | − <h1 | |
| 5 | − className="text-2xl font-semibold mb-4" | |
| 6 | − style={{ color: "var(--text-primary)" }} | |
| 7 | − > | |
| 8 | − Profile | |
| 9 | − </h1> | |
| 10 | − <p style={{ color: "var(--text-secondary)" }}> | |
| 11 | − Edit your profile, bio, and links. | |
| 12 | − </p> | |
| 282 | + <div className="act-stat"> | |
| 283 | + <div className={`act-stat-badge${tone === "green" ? " green" : ""}`}>{stat?.mark ?? label.slice(0, 1)}</div> | |
| 284 | + <div className={`act-stat-value${tone === "green" ? " green" : ""}`}>{value}</div> | |
| 285 | + <div className="act-stat-label">{label}</div> | |
| 13 | 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 | 8 | return ( |
| 3 | − <div className="max-w-4xl mx-auto px-6 py-12"> | |
| 4 | − <h1 | |
| 5 | − className="text-2xl font-semibold mb-4" | |
| 6 | − style={{ color: "var(--text-primary)" }} | |
| 7 | − > | |
| 8 | − Settings | |
| 9 | − </h1> | |
| 10 | − <p style={{ color: "var(--text-secondary)" }}> | |
| 11 | − Manage your API keys, custom CSS, and account settings. | |
| 12 | − </p> | |
| 13 | − </div> | |
| 9 | + <main className="settings-shell"> | |
| 10 | + <style>{settingsStyles}</style> | |
| 11 | + <div className="settings-frame"> | |
| 12 | + <SurfaceNav variant="light" /> | |
| 13 | + <section className="settings-head"> | |
| 14 | + <p>SumTex Profile</p> | |
| 15 | + <h1>Account management</h1> | |
| 16 | + </section> | |
| 17 | + {user ? ( | |
| 18 | + <UserProfile | |
| 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 | 9 | --text-secondary: #666666; |
| 10 | 10 | --text-muted: #888888; |
| 11 | 11 | --border-color: #e0e0e0; |
| 12 | − --link-color: rgba(10, 132, 255, 1); | |
| 13 | − --link-visited: #af52de; | |
| 12 | + --link-color: var(--text-primary); | |
| 13 | + --link-visited: var(--text-secondary); | |
| 14 | 14 | --accent: #333333; |
| 15 | 15 | --accent-muted: #666666; |
| 16 | 16 | |
| @@ -52,8 +52,8 @@ · 35 unmodified lines | ||
| 52 | 52 | --text-secondary: hsl(0 0% 75%); |
| 53 | 53 | --text-muted: hsl(0 0% 50%); |
| 54 | 54 | --border-color: hsl(0 0% 20%); |
| 55 | − --link-color: #79a8ff; | |
| 56 | − --link-visited: #f78fe7; | |
| 55 | + --link-color: var(--text-primary); | |
| 56 | + --link-visited: var(--text-secondary); | |
| 57 | 57 | --accent: hsl(0 0% 100%); |
| 58 | 58 | --accent-muted: hsl(0 0% 75%); |
| 59 | 59 | |
| @@ -140,10 +140,740 @@ body { · 80 unmodified lines | ||
| 140 | 140 | } |
| 141 | 141 | |
| 142 | 142 | a { |
| 143 | − color: var(--link-color); | |
| 143 | + color: inherit; | |
| 144 | 144 | text-decoration: none; |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | 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 | 1 | import type { Metadata } from "next"; |
| 2 | +import { ClerkProvider } from "@clerk/nextjs"; | |
| 2 | 3 | import { GeistSans } from "geist/font/sans"; |
| 3 | 4 | import { GeistMono } from "geist/font/mono"; |
| 4 | 5 | import { ThemeProvider } from "@/lib/theme"; |
| @@ -19,9 +20,11 @@ export default function RootLayout({ · 14 unmodified lines | ||
| 19 | 20 | return ( |
| 20 | 21 | <html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}> |
| 21 | 22 | <body> |
| 22 | − <ThemeProvider> | |
| 23 | − <MathJaxProvider>{children}</MathJaxProvider> | |
| 24 | − </ThemeProvider> | |
| 23 | + <ClerkProvider dynamic> | |
| 24 | + <ThemeProvider> | |
| 25 | + <MathJaxProvider>{children}</MathJaxProvider> | |
| 26 | + </ThemeProvider> | |
| 27 | + </ClerkProvider> | |
| 25 | 28 | </body> |
| 26 | 29 | </html> |
| 27 | 30 | ); |
| @@ -1,105 +1,64 @@ | ||
| 1 | −import { Header } from "@/components/navigation/header"; | |
| 2 | − | |
| 3 | −export default function LandingPage() { | |
| 1 | +export default function HomePage() { | |
| 4 | 2 | return ( |
| 5 | − <div | |
| 6 | − className="min-h-screen flex flex-col" | |
| 7 | − style={{ background: "var(--bg-canvas)" }} | |
| 8 | − > | |
| 9 | − <Header /> | |
| 10 | − <main className="flex-1 flex flex-col items-center justify-center px-6"> | |
| 11 | − <div className="max-w-2xl text-center"> | |
| 12 | − <h1 | |
| 13 | − className="text-4xl font-semibold tracking-tight mb-4" | |
| 14 | − style={{ color: "var(--text-primary)" }} | |
| 15 | − > | |
| 16 | − SumTeX | |
| 17 | − </h1> | |
| 18 | − <p | |
| 19 | − className="text-lg mb-8 leading-relaxed" | |
| 20 | − style={{ color: "var(--text-secondary)" }} | |
| 3 | + <main className="relative min-h-screen overflow-hidden bg-[#111111] text-[#f2f0ea]"> | |
| 4 | + <div className="absolute right-8 top-5 flex items-center gap-3 text-xs text-[#8d8d8d]"> | |
| 5 | + <a href="/play/mental-arithmetic" className="rounded-full border border-[#303030] px-4 py-2 text-[#d8d5ce] no-underline"> | |
| 6 | + Mental Arithmetic | |
| 7 | + </a> | |
| 8 | + <a href="/profile" className="flex h-8 w-8 items-center justify-center rounded-full bg-[#202020] text-[#d8d5ce] no-underline"> | |
| 9 | + S | |
| 10 | + </a> | |
| 11 | + </div> | |
| 12 | + | |
| 13 | + <div className="pointer-events-none absolute inset-0"> | |
| 14 | + <span className="absolute left-[6%] top-[16%] text-[#777]">+</span> | |
| 15 | + <span className="absolute left-[25%] top-[38%] text-[#777]">.</span> | |
| 16 | + <span className="absolute right-[18%] top-[28%] text-[#777]">.</span> | |
| 17 | + <span className="absolute bottom-[28%] left-[58%] text-[#777]">+</span> | |
| 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. | |
| 23 | − Real problems from Rudin, Spivak, Artin, Munkres, and more. Solve | |
| 24 | − them. Track your progress. Get better. | |
| 25 | − </p> | |
| 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> | |
| 37 | + Go | |
| 38 | + </button> | |
| 39 | + </form> | |
| 40 | + </section> | |
| 50 | 41 | |
| 51 | − <div | |
| 52 | − className="mt-16 grid grid-cols-3 gap-8 max-w-3xl w-full" | |
| 53 | − style={{ color: "var(--text-secondary)" }} | |
| 54 | − > | |
| 55 | − <div className="text-center"> | |
| 56 | − <div | |
| 57 | − className="text-2xl font-semibold mb-1" | |
| 58 | − style={{ color: "var(--text-primary)" }} | |
| 59 | − > | |
| 60 | − Textbooks | |
| 61 | − </div> | |
| 62 | − <p className="text-sm"> | |
| 63 | − Problems pulled from qualified mathematical texts. Spivak, | |
| 64 | − Rudin, Artin, Munkres. | |
| 65 | − </p> | |
| 66 | − </div> | |
| 67 | − <div className="text-center"> | |
| 68 | − <div | |
| 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> | |
| 42 | + <footer className="absolute inset-x-0 bottom-6 grid grid-cols-3 items-end px-10 text-xs text-[#676767]"> | |
| 43 | + <div /> | |
| 44 | + <div className="text-center"> | |
| 45 | + <div>Generated drills</div> | |
| 46 | + <div className="mt-1 font-mono text-base text-[#aaa]">11 categories</div> | |
| 47 | + </div> | |
| 48 | + <div className="justify-self-end"> | |
| 49 | + <a href="/play" className="text-[#676767] no-underline"> | |
| 50 | + Play | |
| 51 | + </a> | |
| 52 | + <span className="mx-2">.</span> | |
| 53 | + <a href="/problems" className="text-[#676767] no-underline"> | |
| 54 | + Problems | |
| 55 | + </a> | |
| 56 | + <span className="mx-2">.</span> | |
| 57 | + <a href="/sign-in" className="text-[#676767] no-underline"> | |
| 58 | + Sign in | |
| 59 | + </a> | |
| 91 | 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 | 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 | 10 | export function MathDisplay({ latex, inline = false }: MathDisplayProps) { |
| 11 | − const wrapped = inline ? `$${latex}$` : `$$${latex}$$`; | |
| 12 | − return <MathJax>{wrapped}</MathJax>; | |
| 11 | + return ( | |
| 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 | 1 | import "server-only"; |
| 2 | −import { withAuth } from "@workos-inc/authkit-nextjs"; | |
| 2 | +import { auth } from "@clerk/nextjs/server"; | |
| 3 | 3 | |
| 4 | 4 | export async function getSession() { |
| 5 | − const session = await withAuth(); | |
| 6 | − return session; | |
| 5 | + return auth(); | |
| 7 | 6 | } |
| 8 | 7 | |
| 9 | 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 | 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 | 6 | integer, |
| 7 | 7 | boolean, |
| 8 | 8 | timestamp, |
| 9 | + jsonb, | |
| 9 | 10 | uniqueIndex, |
| 10 | 11 | index, |
| 11 | 12 | } from "drizzle-orm/pg-core"; |
| @@ -19,7 +20,7 @@ export const users = pgTable( · 7 unmodified lines | ||
| 19 | 20 | "users", |
| 20 | 21 | { |
| 21 | 22 | id: uuid("id").primaryKey().defaultRandom(), |
| 22 | − workosId: text("workos_id").notNull(), | |
| 23 | + clerkId: text("clerk_id").notNull(), | |
| 23 | 24 | username: varchar("username", { length: 40 }).notNull(), |
| 24 | 25 | displayName: varchar("display_name", { length: 100 }), |
| 25 | 26 | email: varchar("email", { length: 255 }).notNull(), |
| @@ -36,7 +37,7 @@ export const users = pgTable( · 10 unmodified lines | ||
| 36 | 37 | updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), |
| 37 | 38 | }, |
| 38 | 39 | (t) => [ |
| 39 | − uniqueIndex("users_workos_id_idx").on(t.workosId), | |
| 40 | + uniqueIndex("users_clerk_id_idx").on(t.clerkId), | |
| 40 | 41 | uniqueIndex("users_username_idx").on(t.username), |
| 41 | 42 | uniqueIndex("users_email_idx").on(t.email), |
| 42 | 43 | ] |
| @@ -299,6 +300,65 @@ export const gameSessions = pgTable("game_sessions", { · 256 unmodified lines | ||
| 299 | 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 | 363 | // LEADERBOARD |
| 304 | 364 | // ============================================================ |
| @@ -403,6 +463,7 @@ export const userApiKeys = pgTable( · 98 unmodified lines | ||
| 403 | 463 | export const usersRelations = relations(users, ({ many }) => ({ |
| 404 | 464 | progress: many(userProgress), |
| 405 | 465 | sessions: many(gameSessions), |
| 466 | + arithmeticSessions: many(arithmeticSessions), | |
| 406 | 467 | submittedProblems: many(problems), |
| 407 | 468 | apiKeys: many(userApiKeys), |
| 408 | 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 | 584 | export const forumThreadsRelations = relations(forumThreads, ({ one, many }) => ({ |
| 506 | 585 | category: one(forumCategories, { |
| 507 | 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 | 9 | collections, |
| 10 | 10 | userProgress, |
| 11 | 11 | gameSessions, |
| 12 | + arithmeticSessions, | |
| 13 | + arithmeticAttempts, | |
| 12 | 14 | leaderboardEntries, |
| 13 | 15 | forumCategories, |
| 14 | 16 | forumThreads, |
| @@ -25,6 +27,8 @@ export type Roadmap = InferSelectModel<typeof roadmaps>; · 10 unmodified lines | ||
| 25 | 27 | export type Collection = InferSelectModel<typeof collections>; |
| 26 | 28 | export type UserProgress = InferSelectModel<typeof userProgress>; |
| 27 | 29 | export type GameSession = InferSelectModel<typeof gameSessions>; |
| 30 | +export type ArithmeticSession = InferSelectModel<typeof arithmeticSessions>; | |
| 31 | +export type ArithmeticAttempt = InferSelectModel<typeof arithmeticAttempts>; | |
| 28 | 32 | export type LeaderboardEntry = InferSelectModel<typeof leaderboardEntries>; |
| 29 | 33 | export type ForumCategory = InferSelectModel<typeof forumCategories>; |
| 30 | 34 | export type ForumThread = InferSelectModel<typeof forumThreads>; |