diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index fc2361f..05b9805 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -17,6 +17,20 @@ jobs: quality: runs-on: ubuntu-latest timeout-minutes: 15 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ci + POSTGRES_PASSWORD: ci + POSTGRES_DB: codeforge + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ci -d codeforge" + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/codeforge?schema=public JWT_SECRET: ci-only-not-a-production-secret @@ -36,5 +50,14 @@ jobs: - name: Install dependencies run: npm ci + - name: Prepare database schema + run: npx prisma db push + + - name: Seed database + run: npm run prisma:seed + + - name: Sync visualizer catalog to CI problem IDs + run: npx tsx scripts/sync-visualizer-catalog.ts + - name: Run quality gate run: npm run verify:quality diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..66c9eb2 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,12 @@ +.env +.env.* +!.env.example +.git +.github +.next +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +*.log diff --git a/DEPLOYMENT_RUNBOOK.md b/DEPLOYMENT_RUNBOOK.md index 25440ab..2a17663 100644 --- a/DEPLOYMENT_RUNBOOK.md +++ b/DEPLOYMENT_RUNBOOK.md @@ -41,6 +41,27 @@ GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=https:///api/auth/google/callback ``` +Email OTP requires a Resend API key and a sender address on a Resend-verified +domain. Do not use `onboarding@resend.dev` or `auth@yourdomain.com` in +production. Before promoting, validate the secret shape and send a real +operator-owned smoke email: + +```bash +npm run verify:email +VERIFY_EMAIL_TO=operator@example.com npm run verify:email +``` + +For the linked Vercel project, add the two production secrets with: + +```bash +vercel env add RESEND_API_KEY production +vercel env add RESEND_FROM_EMAIL production +vercel env ls production +``` + +The `vercel env ls production` output must include both `RESEND_API_KEY` and +`RESEND_FROM_EMAIL` before deploying or redeploying production. + Generate a session secret outside the repository, for example: ```bash @@ -97,13 +118,14 @@ curl -i https:///api/problems?page=1\&limit=3 curl -i https:///api/company ``` -`/api/health` must return HTTP `200`, `status: "ok"`, and database status -`"ok"`. It deliberately disables caching and never returns connection strings -or provider error details. +`/api/health` must return HTTP `200`, `status: "ok"`, database status `"ok"`, +and email verification status `"configured"` in production. It deliberately +disables caching and never returns connection strings, provider keys, sender +addresses, or provider error details. -Then manually verify register OTP, login, password reset, Google OAuth (if -enabled), code execution, a deterministic fallback, contest registration/submission, and -room join/leave in staging before production. +Then manually verify register OTP, login OTP, password reset OTP, Google OAuth +(if enabled), code execution, a deterministic fallback, contest +registration/submission, and room join/leave in staging before production. ## 6. Rollback diff --git a/README.md b/README.md index 6718247..a21257a 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,15 @@ The local application runs at `http://localhost:3000`. | `GOOGLE_CLIENT_SECRET` | Optional | Google sign-in client secret. | | `GOOGLE_REDIRECT_URI` | Optional | Google callback URL. | +Email OTP verification uses Resend from server-side route handlers. For +production, create a Resend API key, verify your sending domain in Resend, and +set `RESEND_FROM_EMAIL` to that verified sender. Check the configuration with: + +```bash +npm run verify:email +VERIFY_EMAIL_TO=you@example.com npm run verify:email +``` + Never commit `.env`, `.env.local`, provider keys, database credentials, or OAuth secrets. Use platform-managed secrets in deployed environments. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..ac0d870 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,54 @@ +apply plugin: 'com.android.application' + +android { + namespace = "com.codeforge.revision" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.codeforge.revision" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..bbfb44f --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,19 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b06ddbf --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/codeforge/revision/MainActivity.java b/android/app/src/main/java/com/codeforge/revision/MainActivity.java new file mode 100644 index 0000000..4ebadf5 --- /dev/null +++ b/android/app/src/main/java/com/codeforge/revision/MainActivity.java @@ -0,0 +1,5 @@ +package com.codeforge.revision; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/android/app/src/main/res/drawable-land-hdpi/splash.png b/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-mdpi/splash.png b/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-hdpi/splash.png b/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-mdpi/splash.png b/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c023e50 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2127973 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b441f37 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..72905b8 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ed0605 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9502e47 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4d1e077 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df0f158 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..853db04 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6cdf97c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2960cbb Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8e3093a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..46de6e2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d2ea9ab Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a40d73e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..dcb8df1 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + CodeForge Revision + CodeForge Revision + com.codeforge.revision + com.codeforge.revision + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..f8f0e43 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle new file mode 100644 index 0000000..9a5fa87 --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,3 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/android/variables.gradle b/android/variables.gradle new file mode 100644 index 0000000..ee4ba41 --- /dev/null +++ b/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/app/api/ai/commentator/route.ts b/app/api/ai/commentator/route.ts index 0bf31f6..6978678 100644 --- a/app/api/ai/commentator/route.ts +++ b/app/api/ai/commentator/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { callFreeModel, hasFreeModelProvider, MODELS } from '@/lib/freemodel'; import { rateLimitResponse } from '@/lib/rateLimit'; +import { getSessionFromRequest } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; export const dynamic = 'force-dynamic'; @@ -19,7 +21,7 @@ export interface BattleParticipant { export interface CommentatorRequestBody { roomCode?: string; roomName?: string; - eventType?: 'JOIN' | 'SUBMIT' | 'LEAD_SWAP' | 'FAST_SUBMISSION' | 'HIGH_SCORE' | 'TICK' | 'TYPING_PROGRESS' | 'RUN_CODE' | 'SAMPLE_PASSED' | 'SAMPLE_FAILED' | 'SUBMIT_FAILED' | string; + eventType?: 'JOIN' | 'SUBMIT' | 'LEAD_SWAP' | 'FAST_SUBMISSION' | 'HIGH_SCORE' | 'TICK' | 'TYPING_PROGRESS' | 'CODE_ANALYSIS' | 'RUN_CODE' | 'SAMPLE_PASSED' | 'SAMPLE_FAILED' | 'SUBMIT_FAILED' | string; event?: string; participants?: BattleParticipant[]; problemTitle?: string; @@ -27,6 +29,22 @@ export interface CommentatorRequestBody { language?: string; codeSnippet?: string; linesOfCode?: number; + timeRemainingSeconds?: number; + privacyMode?: boolean | 'private_room'; + codeAnalysis?: { + lineCount?: number; + nonEmptyLineCount?: number; + functionCount?: number; + loopCount?: number; + conditionalCount?: number; + hasInputParsing?: boolean; + hasReturn?: boolean; + hasConsoleOutput?: boolean; + hasTodo?: boolean; + possibleIssue?: string; + phase?: string; + signature?: string; + }; executionResult?: { verdict?: string; stdout?: string; @@ -38,28 +56,176 @@ export interface CommentatorRequestBody { userName?: string; } +type HypeLevel = 'high' | 'medium' | 'low'; + +const VALID_HYPE_LEVELS = new Set(['high', 'medium', 'low']); +const PRIVATE_ROOM_CODE_PATTERN = /^[A-Z0-9-]{4,40}$/; + +function cleanText(value: unknown, fallback: string, maxLength: number): string { + return typeof value === 'string' && value.trim() ? value.trim().slice(0, maxLength) : fallback; +} + +function cleanOptionalText(value: unknown, maxLength: number): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim().slice(0, maxLength) : undefined; +} + +function participantName(participant: BattleParticipant | undefined, fallback: string): string { + return cleanOptionalText(participant?.userName, 80) || cleanOptionalText(participant?.name, 80) || fallback; +} + +function participantScore(participant: BattleParticipant | undefined): number { + const score = participant?.score ?? participant?.scores ?? 0; + return Number.isFinite(score) ? Number(score) : 0; +} + +function participantSolved(participant: BattleParticipant | undefined): number { + const solved = participant?.solved ?? 0; + return Number.isFinite(solved) ? Number(solved) : 0; +} + +function cleanProgress(value: unknown): string { + return typeof value === 'string' && value.trim() ? value.trim().slice(0, 24).toUpperCase() : 'WAITING'; +} + +function cleanCodeAnalysis(value: unknown): Required> { + const analysis = value && typeof value === 'object' ? value as NonNullable : {}; + const safeNumber = (item: unknown, max: number) => Number.isFinite(item) ? Math.max(0, Math.min(max, Number(item))) : 0; + const safeBool = (item: unknown) => item === true; + + return { + lineCount: safeNumber(analysis.lineCount, 100_000), + nonEmptyLineCount: safeNumber(analysis.nonEmptyLineCount, 100_000), + functionCount: safeNumber(analysis.functionCount, 10_000), + loopCount: safeNumber(analysis.loopCount, 10_000), + conditionalCount: safeNumber(analysis.conditionalCount, 10_000), + hasInputParsing: safeBool(analysis.hasInputParsing), + hasReturn: safeBool(analysis.hasReturn), + hasConsoleOutput: safeBool(analysis.hasConsoleOutput), + hasTodo: safeBool(analysis.hasTodo), + possibleIssue: cleanText(analysis.possibleIssue, 'No obvious issue spotted', 120), + phase: cleanText(analysis.phase, 'drafting', 40), + signature: cleanText(analysis.signature, 'empty', 80), + }; +} + +function normalizeAiCommentary(text: string, fallback: string): string { + const normalized = text + .replace(/```[\s\S]*?```/g, '') + .replace(/^[-*#>\s]+/g, '') + .replace(/^["'“”‘’]+|["'“”‘’]+$/g, '') + .replace(/\s+/g, ' ') + .trim(); + + if (normalized.length < 10) return fallback; + + const firstLine = normalized.split(/\r?\n/)[0]?.trim() || fallback; + const firstSentence = firstLine.match(/^(.{1,180}?[.!?])(?:\s|$)/)?.[1] || firstLine.slice(0, 180); + return firstSentence.trim(); +} + +function isUnsafeAiCommentary(text: string, roomCode: string, isPrivateRoom: boolean): boolean { + const normalized = text.toLowerCase(); + const cleanRoomCode = roomCode.trim().toLowerCase(); + if (isPrivateRoom && cleanRoomCode && normalized.includes(cleanRoomCode)) return true; + if (/```|<\/?[a-z][\s\S]*?>|(?:^|\s)(function|class|const|let|var|#include|import\s+\w|public\s+static|console\.log|std::|=>)(?:\s|$)/i.test(text)) return true; + if (/^[-*#>]/.test(text.trim())) return true; + return text.split(/\s+/).filter(Boolean).length > 32; +} + +function formatClock(totalSeconds: number): string { + const safeSeconds = Math.max(0, Math.min(24 * 60 * 60, Math.floor(totalSeconds))); + const minutes = Math.floor(safeSeconds / 60); + const seconds = safeSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function scoreGap(leader: BattleParticipant | undefined, chaser: BattleParticipant | undefined): number { + return Math.max(0, participantScore(leader) - participantScore(chaser)); +} + +async function verifyPrivateRoomAccess(req: NextRequest, roomCode: string, privacyMode: boolean): Promise { + if (!privacyMode || !PRIVATE_ROOM_CODE_PATTERN.test(roomCode)) return null; + + const session = getSessionFromRequest(req); + if (!session?.userId) { + return NextResponse.json({ error: 'Sign in to use private room commentary.' }, { status: 401 }); + } + + const room = await prisma.customRoom.findUnique({ + where: { code: roomCode.toUpperCase() }, + select: { + id: true, + participants: { + where: { userId: session.userId }, + select: { id: true }, + take: 1, + }, + }, + }); + + if (!room || room.participants.length === 0) { + return NextResponse.json({ error: 'Join this private room before using its commentator.' }, { status: 403 }); + } + + return null; +} + +async function readCommentatorBody(req: NextRequest): Promise { + try { + const parsed = await req.json(); + return parsed && typeof parsed === 'object' ? parsed as CommentatorRequestBody : {}; + } catch { + return {}; + } +} + function generateNativeFallback( eventType: string, leaderName: string, leaderScore: number, chaserName: string, problemTitle: string, - roomCode: string, + roomLabel: string, participantCount: number, linesOfCode: number = 0, - executionResult?: { verdict?: string; stdout?: string; stderr?: string; failedTestCase?: string } -): { text: string; hypeLevel: 'high' | 'medium' | 'low' } { + executionResult?: { verdict?: string; stdout?: string; stderr?: string; failedTestCase?: string }, + timeRemainingSeconds?: number, + scoreLead: number = 0, + leaderSolved: number = 0, + leaderProgress: string = 'CODING', + codeAnalysis: Required> = cleanCodeAnalysis(undefined) +): { text: string; hypeLevel: HypeLevel } { const norm = (eventType || 'TICK').toUpperCase(); + const clockText = Number.isFinite(timeRemainingSeconds) ? ` with ${formatClock(Number(timeRemainingSeconds))} left` : ''; + const leadText = scoreLead > 0 ? ` by ${scoreLead} pts` : ''; + const progressText = leaderProgress === 'SOLVED' ? 'has a solve banked' : leaderProgress === 'SUBMITTED' ? 'is waiting on a verdict' : 'is deep in implementation'; if (norm === 'TYPING_PROGRESS') { const options = [ `PLAY-BY-PLAY: ${leaderName} is making rapid progress on "${problemTitle}"! Already written ${linesOfCode} lines of code and building the core logic!`, `CODE ANALYST: ${leaderName} has completed over half of their solution (${linesOfCode} lines)! The algorithm structure is taking shape!`, - `SPEED TYPING: ${leaderName} is flying through the implementation in ${roomCode}! ${linesOfCode} lines locked in so far!`, + `SPEED TYPING: ${leaderName} is flying through the implementation in ${roomLabel}! ${linesOfCode} lines locked in so far!`, ]; return { text: options[Math.floor(Math.random() * options.length)], hypeLevel: 'medium' }; } + if (norm === 'CODE_ANALYSIS') { + const issueText = codeAnalysis.possibleIssue === 'No obvious issue spotted' + ? 'no obvious red flags' + : codeAnalysis.possibleIssue.toLowerCase(); + const structureText = codeAnalysis.loopCount > 0 + ? `${codeAnalysis.loopCount} loop${codeAnalysis.loopCount === 1 ? '' : 's'} in motion` + : codeAnalysis.conditionalCount > 0 + ? `${codeAnalysis.conditionalCount} branch${codeAnalysis.conditionalCount === 1 ? '' : 'es'} shaping the logic` + : 'the skeleton is still taking form'; + const options = [ + `CODE READ: ${leaderName} is in the ${codeAnalysis.phase} phase with ${codeAnalysis.nonEmptyLineCount} active lines and ${structureText}.`, + `ANALYSIS SNAPSHOT: ${leaderName}'s solution shows ${structureText}; ${issueText} so far.`, + `TACTICAL CHECK: ${leaderName} has ${codeAnalysis.functionCount} helper block${codeAnalysis.functionCount === 1 ? '' : 's'} and ${codeAnalysis.nonEmptyLineCount} live lines${clockText}.`, + ]; + return { text: options[Math.floor(Math.random() * options.length)], hypeLevel: codeAnalysis.possibleIssue === 'No obvious issue spotted' ? 'low' : 'medium' }; + } + if (norm === 'RUN_CODE') { const options = [ `TEST EXECUTION: ${leaderName} just ran sample test cases on "${problemTitle}"! Awaiting execution output...`, @@ -96,8 +262,8 @@ function generateNativeFallback( if (norm === 'JOIN') { const options = [ - `A new challenger enters the arena! Room ${roomCode} now has ${participantCount} coders locked in for battle!`, - `ARENA LOCK-IN! The competitive atmosphere spikes in ${roomCode} as coders prepare to tackle "${problemTitle}"!`, + `A new challenger enters the arena! ${roomLabel} now has ${participantCount} coders locked in for battle!`, + `ARENA LOCK-IN! The competitive atmosphere spikes in ${roomLabel} as coders prepare to tackle "${problemTitle}"!`, `CHALLENGER APPROACHING! New player joins the lobby. Who will take early dominance on "${problemTitle}"?`, ]; return { text: options[Math.floor(Math.random() * options.length)], hypeLevel: 'medium' }; @@ -134,17 +300,17 @@ function generateNativeFallback( const options = [ `ACCEPTED! ${leaderName} submits a working solution for "${problemTitle}" and claims ${leaderScore} pts!`, `ALL TEST CASES PASSED! ${leaderName} puts points on the board with clean execution on "${problemTitle}"!`, - `SUBMISSION LOCKED! ${leaderName} delivers a successful solution to the judge!`, + `SUBMISSION LOCKED! ${leaderName} delivers a successful solution to the judge${clockText}!`, ]; return { text: options[Math.floor(Math.random() * options.length)], hypeLevel: 'medium' }; } // Default / TICK / PERIODIC_UPDATE const options = [ - `SHOUTCASTER DESK: ${leaderName || 'Coders'} holding top position with ${leaderScore} pts on "${problemTitle}". Clock ticking down!`, - `ANALYST DESK: Clean time complexity will decide this battle. Room ${roomCode} is in high gear!`, + `SHOUTCASTER DESK: ${leaderName || 'Coders'} leads${leadText} with ${leaderScore} pts and ${leaderSolved} solved on "${problemTitle}"${clockText}!`, + `ANALYST DESK: ${leaderName} ${progressText}; clean time complexity will decide ${roomLabel}${clockText}!`, `ARENA UPDATE: ${participantCount} coders battling live for "${problemTitle}". Every millisecond matters!`, - `INTENSE SHOWDOWN: The algorithm duel in ${roomCode} reaches peak tension! Who will make the next clutch move?`, + `INTENSE SHOWDOWN: The algorithm duel in ${roomLabel} reaches peak tension! Who will make the next clutch move?`, ]; return { text: options[Math.floor(Math.random() * options.length)], hypeLevel: 'low' }; } @@ -153,48 +319,66 @@ export async function POST(req: NextRequest) { try { const limitResponse = rateLimitResponse(req, 'ai:commentator', 30, 60 * 1000); if (limitResponse) return limitResponse; - const body: CommentatorRequestBody = await req.json(); - const cleanText = (value: unknown, fallback: string, maxLength: number): string => - typeof value === 'string' && value.trim() ? value.trim().slice(0, maxLength) : fallback; + const body = await readCommentatorBody(req); const roomCode = cleanText(body.roomCode || body.roomName, 'ARENA-1', 40); + const isPrivateRoom = body.privacyMode === true || body.privacyMode === 'private_room'; + const accessResponse = await verifyPrivateRoomAccess(req, roomCode, isPrivateRoom); + if (accessResponse) return accessResponse; + + const roomLabel = isPrivateRoom ? 'the private room' : roomCode; const eventType = cleanText(body.eventType || body.event, 'TICK', 50).toUpperCase(); - const participants = Array.isArray(body.participants) ? body.participants.slice(0, 10) : []; + const participants = Array.isArray(body.participants) + ? body.participants + .filter((participant) => participant && typeof participant === 'object') + .slice(0, 10) + : []; const problemTitle = cleanText(body.problemTitle || body.activeProblemTitle, 'DSA Challenge', 300); const language = cleanText(body.language, 'cpp', 32); const linesOfCode = Number.isFinite(body.linesOfCode) ? Math.max(0, Math.min(100_000, Number(body.linesOfCode))) : 0; + const timeRemainingSeconds = Number.isFinite(body.timeRemainingSeconds) + ? Math.max(0, Math.min(24 * 60 * 60, Number(body.timeRemainingSeconds))) + : undefined; const executionResult = body.executionResult && typeof body.executionResult === 'object' ? body.executionResult : undefined; - const actorName = cleanText(body.userName || participants[0]?.userName || participants[0]?.name, 'Coder', 80); + const codeAnalysis = cleanCodeAnalysis(body.codeAnalysis); + const actorName = cleanOptionalText(body.userName, 80); // Extract leader and second place participant details - const sortedParticipants = [...participants].sort( - (a, b) => (b.score ?? b.scores ?? 0) - (a.score ?? a.scores ?? 0) - ); - const leader = sortedParticipants[0] || { userName: actorName, name: actorName, score: 0, scores: 0 }; - const chaser = sortedParticipants[1] || { userName: 'Opponent', name: 'Opponent', score: 0, scores: 0 }; + const sortedParticipants = [...participants].sort((a, b) => participantScore(b) - participantScore(a)); + const leader = sortedParticipants[0]; + const chaser = sortedParticipants[1]; - const leaderName = actorName || leader.userName || leader.name || 'Top Coder'; - const leaderScore = leader.score ?? leader.scores ?? 0; - const chaserName = chaser.userName || chaser.name || ''; + const leaderName = participantName(leader, actorName || 'Top Coder'); + const featuredName = actorName || leaderName; + const leaderScore = participantScore(leader); + const chaserName = chaser ? participantName(chaser, 'Opponent') : ''; + const leaderSolved = participantSolved(leader); + const leaderProgress = cleanProgress(leader?.progress); + const lead = scoreGap(leader, chaser); // Generate native fallback commentary const fallback = generateNativeFallback( eventType, - leaderName, + featuredName, leaderScore, chaserName, problemTitle, - roomCode, + roomLabel, participants.length, linesOfCode, - executionResult + executionResult, + timeRemainingSeconds, + lead, + leaderSolved, + leaderProgress, + codeAnalysis ); let commentaryText = fallback.text; - let hypeLevel: 'high' | 'medium' | 'low' = fallback.hypeLevel; + let hypeLevel: HypeLevel = fallback.hypeLevel; // Call FreeModel guided if configured if (hasFreeModelProvider()) { @@ -202,11 +386,15 @@ export async function POST(req: NextRequest) { const systemInstruction = 'You are an elite, high-energy esports shoutcaster broadcasting a competitive coding duel. ' + 'Give granular play-by-play commentary like a real sports caster (e.g. mention player progress, line count, failing test cases, getting close, or debugging). ' + - 'Write exactly 1 punchy, exciting callout line (max 25 words). Use player names and clear text only.'; + 'Write exactly 1 punchy, exciting callout line (max 25 words). Use player names and clear text only. ' + + 'No markdown, bullets, emojis, quotation marks, source code, account details, or private room codes.'; const userInstruction = - `Room Code: ${roomCode}. Event: ${eventType}. Player: ${leaderName}. Problem: "${problemTitle}". `+ - `Lines of code written: ${linesOfCode}. Test Verdict: ${executionResult?.verdict || 'N/A'}. `+ + `Arena: ${roomLabel}. Event: ${eventType}. Player: ${featuredName}. Leader: ${leaderName} (${leaderScore} pts, ${leaderSolved} solved, ${leaderProgress}). `+ + `Score lead: ${lead}. Participants: ${participants.length}. Problem: "${problemTitle}". `+ + `Lines of code written: ${linesOfCode}. Code phase: ${codeAnalysis.phase}. Structure: ${codeAnalysis.nonEmptyLineCount} active lines, `+ + `${codeAnalysis.functionCount} functions, ${codeAnalysis.loopCount} loops, ${codeAnalysis.conditionalCount} conditionals. `+ + `Static issue: ${codeAnalysis.possibleIssue}. Time remaining: ${timeRemainingSeconds !== undefined ? formatClock(timeRemainingSeconds) : 'N/A'}. Test Verdict: ${executionResult?.verdict || 'N/A'}. `+ `Language: ${language}. Deliver the play-by-play shoutcast line now.`; const aiResponse = await callFreeModel({ @@ -215,15 +403,18 @@ export async function POST(req: NextRequest) { userInstruction, temperature: 0.8, maxTokens: 80, + timeoutMs: 3500, fallbackText: fallback.text, }); - if (aiResponse && aiResponse.trim().length > 10) { - const cleanedText = aiResponse.trim().replace(/^["']|["']$/g, ''); - commentaryText = cleanedText; + if (aiResponse) { + const normalizedResponse = normalizeAiCommentary(aiResponse, fallback.text); + commentaryText = isUnsafeAiCommentary(normalizedResponse, roomCode, isPrivateRoom) + ? fallback.text + : normalizedResponse; if (['LEAD_SWAP', 'FAST_SUBMISSION', 'HIGH_SCORE', 'SUBMIT_FAILED', 'SAMPLE_PASSED'].includes(eventType)) { hypeLevel = 'high'; - } else if (['SUBMIT', 'SUBMIT_ATTEMPT', 'JOIN', 'TYPING_PROGRESS', 'RUN_CODE'].includes(eventType)) { + } else if (['SUBMIT', 'SUBMIT_ATTEMPT', 'JOIN', 'TYPING_PROGRESS', 'CODE_ANALYSIS', 'RUN_CODE'].includes(eventType)) { hypeLevel = 'medium'; } } @@ -235,7 +426,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ commentary: commentaryText, timestamp: Date.now(), - hypeLevel, + hypeLevel: VALID_HYPE_LEVELS.has(hypeLevel) ? hypeLevel : 'medium', speaker: 'Shoutcaster', success: true, }); @@ -243,13 +434,13 @@ export async function POST(req: NextRequest) { console.error('Error in guided Commentator route:', error); return NextResponse.json( { - commentary: ' SHOUTCASTER DESK: High-intensity competitive coding underway in the arena!', + commentary: 'SHOUTCASTER DESK: High-intensity competitive coding underway in the arena!', timestamp: Date.now(), hypeLevel: 'medium', speaker: 'Shoutcaster', - success: false, - }, - { status: 500 } + success: true, + source: 'last_resort_fallback', + } ); } } diff --git a/app/api/execute/route.ts b/app/api/execute/route.ts index 7d9624c..24cada8 100644 --- a/app/api/execute/route.ts +++ b/app/api/execute/route.ts @@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { executeCode, isSupportedLanguage } from '@/lib/piston'; import { ExecutionVerdict, TestCaseResult } from '@/lib/types'; -import { normalizeOutput } from '@/lib/output'; +import { outputsEquivalent } from '@/lib/output'; import { checkRateLimit } from '@/lib/rateLimit'; +import { getSessionFromRequest } from '@/lib/auth'; +import { learnFromFailedAttempt } from '@/lib/revisionLearning'; export async function POST(request: NextRequest) { try { @@ -16,6 +18,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); const { problemId, language, code, customInput } = body; + const session = getSessionFromRequest(request); if (typeof language !== 'string' || typeof code !== 'string' || !language.trim() || !code.trim()) { return NextResponse.json( @@ -56,12 +59,34 @@ export async function POST(request: NextRequest) { // Scenario 1: Custom input execution if (customInput !== undefined && customInput !== null) { const result = await executeCode(language, code, customInput, problem?.slug); + const failedAttempt = result.verdict !== 'Accepted' + ? { + input: customInput, + expectedOutput: 'N/A (Custom Run)', + actualOutput: result.stdout, + error: result.stderr, + } + : null; + const learning = session?.userId && problem + ? await learnFromFailedAttempt({ + prisma, + userId: session.userId, + problem, + verdict: result.verdict, + failedTestCase: failedAttempt, + }).catch((error: unknown) => { + console.error('Error learning from failed custom run:', error); + return null; + }) + : null; + return NextResponse.json({ verdict: result.verdict, stdout: result.stdout, stderr: result.stderr, executionTime: result.executionTime, memory: result.memory, + learning, testResults: [ { passed: result.verdict === 'Accepted', @@ -103,6 +128,12 @@ export async function POST(request: NextRequest) { const testResults: TestCaseResult[] = []; let firstStdout = ''; let firstStderr = ''; + let failedTestCaseInfo: { + input: string; + expectedOutput: string; + actualOutput: string; + error?: string; + } | null = null; for (const tc of sampleTestCases) { const execResult = await executeCode(language, code, tc.input, selectedProblem.slug); @@ -117,6 +148,12 @@ export async function POST(request: NextRequest) { if (execResult.verdict === 'Compilation Error') { overallVerdict = 'Compilation Error'; + failedTestCaseInfo = { + input: tc.input, + expectedOutput: tc.expectedOutput, + actualOutput: execResult.stdout, + error: execResult.stderr, + }; testResults.push({ testCaseId: tc.id, passed: false, @@ -132,15 +169,28 @@ export async function POST(request: NextRequest) { } else if (execResult.verdict === 'Runtime Error') { overallVerdict = 'Runtime Error'; tcPassed = false; + if (!failedTestCaseInfo) { + failedTestCaseInfo = { + input: tc.input, + expectedOutput: tc.expectedOutput, + actualOutput: execResult.stdout, + error: execResult.stderr, + }; + } } else if (execResult.verdict === 'TLE') { overallVerdict = 'TLE'; tcPassed = false; + if (!failedTestCaseInfo) { + failedTestCaseInfo = { + input: tc.input, + expectedOutput: tc.expectedOutput, + actualOutput: execResult.stdout, + error: 'Time Limit Exceeded', + }; + } } else { // Compare outputs stripping trailing whitespace/newlines - const normActual = normalizeOutput(execResult.stdout); - const normExpected = normalizeOutput(tc.expectedOutput); - - if (normActual === normExpected) { + if (outputsEquivalent(execResult.stdout, tc.expectedOutput)) { tcPassed = true; tcVerdict = 'Accepted'; } else { @@ -149,6 +199,14 @@ export async function POST(request: NextRequest) { if (overallVerdict === 'Accepted') { overallVerdict = 'Wrong Answer'; } + if (!failedTestCaseInfo) { + failedTestCaseInfo = { + input: tc.input, + expectedOutput: tc.expectedOutput, + actualOutput: execResult.stdout, + error: execResult.stderr, + }; + } } } @@ -166,6 +224,18 @@ export async function POST(request: NextRequest) { } const avgTime = Number((totalExecutionTime / sampleTestCases.length).toFixed(3)); + const learning = session?.userId + ? await learnFromFailedAttempt({ + prisma, + userId: session.userId, + problem: selectedProblem, + verdict: overallVerdict, + failedTestCase: failedTestCaseInfo, + }).catch((error: unknown) => { + console.error('Error learning from failed sample run:', error); + return null; + }) + : null; return NextResponse.json({ verdict: overallVerdict, @@ -173,6 +243,7 @@ export async function POST(request: NextRequest) { stderr: firstStderr, executionTime: avgTime, memory: maxMemory, + learning, testResults, }); } diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 7f2bce1..b991094 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { hasConfiguredEmailVerification } from '@/lib/email'; export const dynamic = 'force-dynamic'; @@ -17,14 +18,17 @@ function healthResponse( export async function GET() { const hasDatabase = Boolean(process.env.DATABASE_URL?.trim()); const hasJwtSecret = Boolean(process.env.JWT_SECRET?.trim()); + const hasEmailVerification = hasConfiguredEmailVerification(); + const requiresEmailVerification = process.env.NODE_ENV === 'production'; - if (!hasDatabase || !hasJwtSecret) { + if (!hasDatabase || !hasJwtSecret || (requiresEmailVerification && !hasEmailVerification)) { return healthResponse( { status: 'not_ready', checks: { database: hasDatabase ? 'configured' : 'missing', authentication: hasJwtSecret ? 'configured' : 'missing', + emailVerification: hasEmailVerification ? 'configured' : 'missing', }, }, 503, @@ -40,6 +44,7 @@ export async function GET() { checks: { database: 'ok', authentication: 'configured', + emailVerification: hasEmailVerification ? 'configured' : 'dev_fallback', }, }, 200); } catch (error: unknown) { @@ -50,6 +55,7 @@ export async function GET() { checks: { database: 'unavailable', authentication: 'configured', + emailVerification: hasEmailVerification ? 'configured' : 'missing', }, }, 503, diff --git a/app/api/leaderboard/[id]/route.ts b/app/api/leaderboard/[id]/route.ts index d47d52a..992ca1c 100644 --- a/app/api/leaderboard/[id]/route.ts +++ b/app/api/leaderboard/[id]/route.ts @@ -58,8 +58,6 @@ async function loadProfile(id: string) { where: { id }, select: { id: true, name: true, createdAt: true }, }); - if (!user && id !== 'guest') return null; - const displayUser = user || { id: 'guest', name: 'Guest Coder', createdAt: new Date(0) }; const [progress, submissions, totalSubmissionCount, acceptedSubmissionCount, accepted] = await prisma.$transaction([ prisma.userProgress.findUnique({ where: { userId: id }, select: { streak: true, lastActiveDate: true } }), @@ -78,6 +76,28 @@ async function loadProfile(id: string) { }), ]); + const ratingHistory = await prisma.userRating.findMany({ + where: { userId: id }, + select: { rating: true, delta: true, timestamp: true }, + orderBy: { timestamp: 'desc' }, + take: 20, + }); + + const hasPublicActivity = + Boolean(user) || + id === 'guest' || + Boolean(progress) || + totalSubmissionCount > 0 || + ratingHistory.length > 0; + + if (!hasPublicActivity) return null; + + const displayUser = user || { + id, + name: id === 'guest' ? 'Guest Coder' : `Coder (${id.slice(0, 6)})`, + createdAt: new Date(0), + }; + const accuracy = totalSubmissionCount ? Math.round((acceptedSubmissionCount / totalSubmissionCount) * 1000) / 10 : 0; const solved = { easy: 0, medium: 0, hard: 0, total: accepted.length }; accepted.forEach(({ problem }) => { @@ -86,12 +106,6 @@ async function loadProfile(id: string) { if (problem.difficulty === 'HARD') solved.hard += 1; }); - const ratingHistory = await prisma.userRating.findMany({ - where: { userId: id }, - select: { rating: true, delta: true, timestamp: true }, - orderBy: { timestamp: 'desc' }, - take: 20, - }); const rating = ratingHistory[0]?.rating || 0; return { diff --git a/app/api/revision/route.ts b/app/api/revision/route.ts index 4be2b57..49bb06e 100644 --- a/app/api/revision/route.ts +++ b/app/api/revision/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { getRequestToken, verifyToken } from '@/lib/auth'; import { rateLimitResponse } from '@/lib/rateLimit'; +import type { RevisionCardDTO, RevisionDeckResponse } from '@/lib/types'; +import { isRevisionQuality, nextRevisionInterval } from '@/lib/revisionSchedule'; export const dynamic = 'force-dynamic'; @@ -30,19 +32,44 @@ export async function GET(req: NextRequest) { }); const now = new Date(); - const dueCards = cards.filter((c) => new Date(c.dueDate) <= now); + const dueCards = cards.filter((c) => c.dueDate <= now); const masteredCount = cards.filter((c) => c.repetitions >= 3).length; + const nextDueCard = cards.find((c) => c.dueDate > now) || null; + const serializeCard = (card: (typeof cards)[number]): RevisionCardDTO => ({ + id: card.id, + problemId: card.problemId, + pattern: card.pattern, + keyTakeaway: card.keyTakeaway, + timeComplexity: card.timeComplexity, + spaceComplexity: card.spaceComplexity, + interval: card.interval, + repetitions: card.repetitions, + dueDate: card.dueDate.toISOString(), + lastReviewedAt: card.lastReviewedAt?.toISOString() || null, + failureCount: card.failureCount, + lastFailureType: card.lastFailureType, + lastError: card.lastError, + lastFailedInput: card.lastFailedInput, + lastExpectedOutput: card.lastExpectedOutput, + lastActualOutput: card.lastActualOutput, + learnedAt: card.learnedAt?.toISOString() || null, + createdAt: card.createdAt.toISOString(), + problem: card.problem, + }); - return NextResponse.json({ - cards, - dueCards, + const response: RevisionDeckResponse = { + cards: cards.map(serializeCard), + dueCards: dueCards.map(serializeCard), stats: { totalCards: cards.length, dueTodayCount: dueCards.length, masteredCount, learnedMistakeCount: cards.filter((c) => c.failureCount > 0).length, + nextDueDate: nextDueCard?.dueDate.toISOString() || null, }, - }); + }; + + return NextResponse.json(response); } catch (error: unknown) { console.error('Error fetching revision cards:', error); return NextResponse.json({ error: 'Failed to fetch revision flashcards' }, { status: 500 }); @@ -67,7 +94,7 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { cardId, quality } = body; // quality: 'HARD' | 'GOOD' | 'EASY' - if (!cardId || !quality) { + if (typeof cardId !== 'string' || typeof quality !== 'string' || !cardId || !quality) { return NextResponse.json({ error: 'cardId and quality are required' }, { status: 400 }); } @@ -79,20 +106,12 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Flashcard not found' }, { status: 404 }); } - if (!['HARD', 'GOOD', 'EASY'].includes(quality)) { + if (!isRevisionQuality(quality)) { return NextResponse.json({ error: 'quality must be HARD, GOOD, or EASY' }, { status: 400 }); } - let nextInterval = card.interval; let nextRepetitions = card.repetitions + 1; - - if (quality === 'HARD') { - nextInterval = 1; - } else if (quality === 'GOOD') { - nextInterval = Math.max(2, Math.round(card.interval * 2)); - } else if (quality === 'EASY') { - nextInterval = Math.max(3, Math.round(card.interval * 3.5)); - } + const nextInterval = nextRevisionInterval(card.interval, quality); const nextDueDate = new Date(Date.now() + nextInterval * 24 * 60 * 60 * 1000); diff --git a/app/api/submissions/route.ts b/app/api/submissions/route.ts index 3704d16..d83fd6b 100644 --- a/app/api/submissions/route.ts +++ b/app/api/submissions/route.ts @@ -4,8 +4,8 @@ import { Prisma } from '@prisma/client'; import { executeCode, isSupportedLanguage } from '@/lib/piston'; import { ExecutionVerdict, TestCaseResult } from '@/lib/types'; import { getSessionFromRequest } from '@/lib/auth'; -import { buildRevisionLearning, snapshotForRevision } from '@/lib/revisionLearning'; -import { normalizeOutput } from '@/lib/output'; +import { learnFromFailedAttempt } from '@/lib/revisionLearning'; +import { outputsEquivalent } from '@/lib/output'; import { syncAcceptedProgress } from '@/lib/progress'; import { rateLimitResponse } from '@/lib/rateLimit'; @@ -158,10 +158,7 @@ export async function POST(request: NextRequest) { } // Check string output matching - const normActual = normalizeOutput(execResult.stdout); - const normExpected = normalizeOutput(tc.expectedOutput); - - if (normActual === normExpected) { + if (outputsEquivalent(execResult.stdout, tc.expectedOutput)) { passedCount++; testResults.push({ testCaseId: tc.id, @@ -246,7 +243,6 @@ export async function POST(request: NextRequest) { }, update: { dueDate: nextDueDate, - keyTakeaway: `Mastered ${problem.title}. Review the learned edge cases and optimal approach.`, }, }); } catch (error: unknown) { @@ -262,46 +258,23 @@ export async function POST(request: NextRequest) { if (overallVerdict !== 'Accepted' && failedTestCaseInfo) { try { - const revision = buildRevisionLearning(overallVerdict, problem.topicTags, failedTestCaseInfo); - const snapshot = snapshotForRevision(failedTestCaseInfo); - const learnedCard = await prisma.revisionCard.upsert({ - where: { - userId_problemId: { - userId: activeUserId, - problemId: problem.id, - }, - }, - create: { - userId: activeUserId, - problemId: problem.id, - pattern: revision.pattern, - keyTakeaway: revision.keyTakeaway, - interval: 1, - repetitions: 0, - dueDate: new Date(), - failureCount: 1, - lastFailureType: revision.failureType, - ...snapshot, - learnedAt: new Date(), - }, - update: { - pattern: revision.pattern, - keyTakeaway: revision.keyTakeaway, - interval: 1, - repetitions: 0, - dueDate: new Date(), - failureCount: { increment: 1 }, - lastFailureType: revision.failureType, - ...snapshot, - learnedAt: new Date(), - }, - }); + const failedTestCaseIsSample = testCases.find((testCase) => testCase.id === failedTestCaseId)?.isSample ?? false; + const revisionFailedCase = failedTestCaseIsSample + ? failedTestCaseInfo + : { + input: '[hidden test case]', + expectedOutput: '[hidden test case]', + actualOutput: failedTestCaseInfo.actualOutput, + error: failedTestCaseInfo.error, + }; - learning = { - failureCount: learnedCard.failureCount, - failureType: revision.failureType, - pattern: revision.pattern, - }; + learning = await learnFromFailedAttempt({ + prisma, + userId: activeUserId, + problem, + verdict: overallVerdict, + failedTestCase: revisionFailedCase, + }); } catch (error: unknown) { console.error('Error learning from failed submission:', error); } diff --git a/app/contests/room/[code]/page.tsx b/app/contests/room/[code]/page.tsx index b27690e..8eed24a 100644 --- a/app/contests/room/[code]/page.tsx +++ b/app/contests/room/[code]/page.tsx @@ -36,6 +36,7 @@ import { useToast } from '@/components/ui/Toast'; import { useAuth } from '@/context/AuthContext'; import { LiveCommentator } from '@/components/contests/LiveCommentator'; import { JudgeScorecardModal, JudgeReportProps } from '@/components/contests/JudgeScorecardModal'; +import { ProblemMarkdown } from '@/components/ui/ProblemMarkdown'; interface ExecutionResult { verdict?: string; @@ -116,6 +117,7 @@ export default function BattleRoomPage() { const [lastEvent, setLastEvent] = useState('JOIN'); const prevTopLeaderRef = React.useRef<{ id: string; score: number } | null>(null); const editorTemplateKeyRef = useRef(null); + const hostUserId = room?.participants.find((participant) => participant.userName === room.hostName)?.userId; const currentUserId = user?.id || battleUserId || `guest_local`; const currentUserName = user?.name || 'Guest Coder'; @@ -444,36 +446,36 @@ export default function BattleRoomPage() { const seconds = (remainingSeconds % 60).toString().padStart(2, '0'); const viewerParticipant = room.participants.find((participant) => participant.userId === currentUserId); const winner = room.winnerId ? room.participants.find((participant) => participant.userId === room.winnerId) : null; - const isHost = (user?.name && user.name === room.hostName) || (room.participants.length > 0 && room.participants[0].userId === currentUserId); + const isHost = hostUserId ? hostUserId === currentUserId : Boolean(user?.name && user.name === room.hostName); return ( -
+
setIsJudgeModalOpen(false)} report={judgeReport} /> - {/* Top Arena Navigation Bar */} -
-
+ {/* Compact room command bar */} +
+
+
-
-
- - Speed Battle +
+
+ + Speed Battle -

{room.name}

+

{room.name}

+
+

Host: {room.hostName}

-

Host: {room.hostName}

-
- {/* Room Code Badge & Copy */} -
-
+
+
CODE: {roomCode}
-
+
{room.participants.length} / {room.maxPlayers} Friends
@@ -496,37 +498,17 @@ export default function BattleRoomPage() { )} {room.status !== 'WAITING' && ( -
+
{room.status === 'FINISHED' ? 'MATCH OVER' : `${minutes}:${seconds}`}
)} - - {/* Host Close Room & Participant Leave Buttons */} - - - {isHost && ( - - )} +
@@ -544,20 +526,20 @@ export default function BattleRoomPage() { )} {/* Main Grid: Left Problem & Editor, Right Live Leaderboard */} -
+
{/* Workspace: 3 Columns on Large Screens */} -
+
{/* Problem Selector Tabs */} -
+
{problems.map((p, idx) => { const isSolved = solvedProblems[p.id]; return ( + + + + + + ) : step === 'email' ? ( +
+ + +
+ ) : ( +
+ + + +
+ )} +
+ + ); +} diff --git a/app/mobile/page.tsx b/app/mobile/page.tsx new file mode 100644 index 0000000..0e2c6db --- /dev/null +++ b/app/mobile/page.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; + +export default function MobileAppEntryPage() { + const router = useRouter(); + + useEffect(() => { + let active = true; + + async function routeUser() { + try { + const response = await fetch('/api/auth/me', { + credentials: 'include', + cache: 'no-store', + }); + const data = await response.json().catch(() => null); + if (!active) return; + router.replace(data?.user ? '/mobile/revision' : '/mobile/login'); + } catch { + if (active) router.replace('/mobile/login'); + } + } + + routeUser(); + return () => { + active = false; + }; + }, [router]); + + return ( +
+
+ +

Opening CodeForge Revision...

+
+
+ ); +} diff --git a/app/mobile/profile/page.tsx b/app/mobile/profile/page.tsx new file mode 100644 index 0000000..1be1f7b --- /dev/null +++ b/app/mobile/profile/page.tsx @@ -0,0 +1,171 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + BookOpenCheck, + ChevronRight, + Loader2, + LogOut, + Mail, + RefreshCw, + ShieldCheck, + User, +} from 'lucide-react'; +import type { RevisionDeckResponse, RevisionDeckStats } from '@/lib/types'; + +interface MobileUser { + id: string; + name: string; + email: string; + role: string; + createdAt?: string; +} + +const EMPTY_STATS: RevisionDeckStats = { + totalCards: 0, + dueTodayCount: 0, + masteredCount: 0, + learnedMistakeCount: 0, + nextDueDate: null, +}; + +function formatDate(value?: string) { + if (!value) return 'CodeForge learner'; + return `Joined ${new Intl.DateTimeFormat(undefined, { month: 'short', year: 'numeric' }).format(new Date(value))}`; +} + +export default function MobileProfilePage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [stats, setStats] = useState(EMPTY_STATS); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const loadProfile = async () => { + setRefreshing(true); + try { + const meResponse = await fetch('/api/auth/me', { + credentials: 'include', + cache: 'no-store', + }); + const meData = await meResponse.json().catch(() => null); + if (!meData?.user) { + router.replace('/mobile/login'); + return; + } + + setUser(meData.user); + const deckResponse = await fetch('/api/revision', { + credentials: 'include', + cache: 'no-store', + }); + const deckData = (await deckResponse.json().catch(() => null)) as RevisionDeckResponse | null; + setStats(deckData?.stats || EMPTY_STATS); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + loadProfile(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const logout = async () => { + await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'include', + }).catch(() => undefined); + router.replace('/mobile/login'); + }; + + if (loading) { + return ( +
+
+ +

Loading your profile...

+
+
+ ); + } + + return ( +
+
+
+ + + +
+

CodeForge App

+

Profile

+
+ +
+ +
+
+ +
+

{user?.name || 'Coder'}

+

+ {user?.email} +

+

+ {formatDate(user?.createdAt)} +

+
+ +
+
+

Total Deck

+

{stats.totalCards}

+
+
+

Due Today

+

{stats.dueTodayCount}

+
+
+

Mastered

+

{stats.masteredCount}

+
+
+

Mistakes

+

{stats.learnedMistakeCount}

+
+
+ +
+ + Open Revision Deck + + +
+
+
+ ); +} diff --git a/app/mobile/revision/page.tsx b/app/mobile/revision/page.tsx new file mode 100644 index 0000000..df2cc57 --- /dev/null +++ b/app/mobile/revision/page.tsx @@ -0,0 +1,357 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { + AlertCircle, + BookOpenCheck, + CalendarClock, + CheckCircle2, + ChevronRight, + Loader2, + RefreshCw, + RotateCw, + User, +} from 'lucide-react'; +import { DifficultyBadge } from '@/components/ui/DifficultyBadge'; +import type { RevisionCardDTO, RevisionDeckResponse, RevisionDeckStats } from '@/lib/types'; +import type { RevisionQuality } from '@/lib/revisionSchedule'; + +const EMPTY_STATS: RevisionDeckStats = { + totalCards: 0, + dueTodayCount: 0, + masteredCount: 0, + learnedMistakeCount: 0, + nextDueDate: null, +}; + +const QUALITY_COPY: Record = { + HARD: { + label: 'Hard', + helper: 'Review tomorrow', + className: 'bg-rose-500 text-white active:bg-rose-400', + }, + GOOD: { + label: 'Good', + helper: 'Space it out', + className: 'bg-amber-400 text-black active:bg-amber-300', + }, + EASY: { + label: 'Easy', + helper: 'Push further', + className: 'bg-emerald-400 text-black active:bg-emerald-300', + }, +}; + +function compactDate(value: string | null) { + if (!value) return 'No upcoming cards'; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(new Date(value)); +} + +function parseTopicTags(topicTags: string) { + try { + const parsed = JSON.parse(topicTags); + return Array.isArray(parsed) ? parsed.slice(0, 3).filter((tag) => typeof tag === 'string') : []; + } catch { + return []; + } +} + +export default function MobileRevisionPage() { + const [cards, setCards] = useState([]); + const [dueCards, setDueCards] = useState([]); + const [stats, setStats] = useState(EMPTY_STATS); + const [currentIndex, setCurrentIndex] = useState(0); + const [showAnswer, setShowAnswer] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [submittingQuality, setSubmittingQuality] = useState(null); + + const activeCard = dueCards[currentIndex] || null; + + const loadDeck = useCallback(async (signal?: AbortSignal) => { + try { + setError(null); + const response = await fetch('/api/revision', { + credentials: 'include', + signal, + }); + + if (response.status === 401) { + setError('Sign in to load your revision deck on this phone.'); + return; + } + + if (!response.ok) throw new Error('Revision deck failed to load'); + + const data = (await response.json()) as RevisionDeckResponse; + setCards(data.cards || []); + setDueCards(data.dueCards || []); + setStats(data.stats || EMPTY_STATS); + setCurrentIndex(0); + setShowAnswer(false); + } catch (err) { + if (!(err instanceof DOMException && err.name === 'AbortError')) { + setError('Could not reach your revision deck. Check your connection and try again.'); + } + } finally { + if (!signal?.aborted) setLoading(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + loadDeck(controller.signal); + return () => controller.abort(); + }, [loadDeck]); + + useEffect(() => { + setShowAnswer(false); + }, [activeCard?.id]); + + const topicTags = activeCard?.problem?.topicTags ? parseTopicTags(activeCard.problem.topicTags) : []; + + const rateCard = async (quality: RevisionQuality) => { + if (!activeCard || submittingQuality) return; + + setSubmittingQuality(quality); + try { + const response = await fetch('/api/revision', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cardId: activeCard.id, quality }), + }); + + if (response.status === 401) { + setError('Your session expired. Sign in again to keep reviewing.'); + return; + } + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.error || 'Could not save review'); + } + + const remainingDueCards = dueCards.filter((card) => card.id !== activeCard.id); + setDueCards(remainingDueCards); + setStats((previous) => ({ + ...previous, + dueTodayCount: Math.max(0, previous.dueTodayCount - 1), + })); + setCurrentIndex((previous) => Math.min(previous, Math.max(remainingDueCards.length - 1, 0))); + setShowAnswer(false); + loadDeck(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not save review'); + } finally { + setSubmittingQuality(null); + } + }; + + if (loading) { + return ( +
+
+ +

Loading your revision deck...

+
+
+ ); + } + + const completedToday = Math.max(0, stats.dueTodayCount - dueCards.length); + const totalToday = completedToday + dueCards.length; + const progress = totalToday > 0 ? Math.round((completedToday / totalToday) * 100) : 100; + + return ( +
+
+
+ + + +
+

CodeForge

+

Revision Deck

+
+ +
+ +
+
+

Due

+

{dueCards.length}

+
+
+

Mastered

+

{stats.masteredCount}

+
+
+

Next

+

{compactDate(stats.nextDueDate)}

+
+
+ + {error && ( +
+
+ +
+

{error}

+ + Sign in + +
+
+
+ )} + + {activeCard ? ( +
+
+
+ {completedToday + 1} of {totalToday} + {progress}% done +
+
+
+
+
+ + + +
+ {(Object.keys(QUALITY_COPY) as RevisionQuality[]).map((quality) => { + const copy = QUALITY_COPY[quality]; + return ( + + ); + })} +
+
+ ) : ( +
+
+ +
+

+ {cards.length > 0 ? 'All caught up' : 'No revision cards yet'} +

+

+ {cards.length > 0 + ? 'Your due cards are done. Come back when the next scheduled card unlocks.' + : 'Solve problems and submit attempts on CodeForge to build your personal deck.'} +

+
+ + Open Profile + + +
+

+ Total cards: {stats.totalCards} +

+
+ )} +
+
+ ); +} diff --git a/app/mock-interview/page.tsx b/app/mock-interview/page.tsx index 3b79546..ae79055 100644 --- a/app/mock-interview/page.tsx +++ b/app/mock-interview/page.tsx @@ -21,6 +21,7 @@ import CodeEditor from '@/components/editor/CodeEditor'; import { EvaluationReport } from '@/app/api/ai/mock-interview/route'; import { useToast } from '@/components/ui/Toast'; import { useDialogAccessibility } from '@/lib/useDialogAccessibility'; +import { ProblemMarkdown } from '@/components/ui/ProblemMarkdown'; const COMPANIES = ['Google', 'Meta', 'Amazon', 'Apple', 'Netflix', 'Microsoft', 'Uber']; const TOPICS = [ @@ -214,7 +215,7 @@ export default function MockInterviewPage() { initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} - className="min-h-screen bg-slate-950 text-slate-100 flex flex-col font-sans" + className="min-h-[calc(100svh-56px)] bg-slate-950 text-slate-100 flex flex-col font-sans" > {/* SETUP STAGE */} {stage === 'setup' && ( @@ -303,14 +304,14 @@ export default function MockInterviewPage() { {/* INTERVIEW STAGE */} {stage === 'interview' && ( -
+
{/* Header Bar */} -
-
+
+
{selectedCompany} Interview - {selectedTopic} + {selectedTopic}
{/* Timer */} @@ -335,9 +336,9 @@ export default function MockInterviewPage() {
{/* Main Interview Body: 2 Columns */} -
+
{/* Left Column: Live Chat with Staff Engineer (5 Cols) */} -
+
Senior Staff Engineer Interviewer @@ -357,13 +358,17 @@ export default function MockInterviewPage() { )}
- {m.content} + {m.role === 'assistant' ? ( + + ) : ( + m.content + )}
{m.role === 'user' && ( @@ -411,7 +416,7 @@ export default function MockInterviewPage() {
{/* Right Column: Code Workspace (7 Cols) */} -
+
diff --git a/app/page.tsx b/app/page.tsx index 40a13ef..87e9fef 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,7 +12,7 @@ export default function Home() { className="min-h-screen text-slate-100 overflow-x-hidden relative" style={{ background: - 'radial-gradient(ellipse 80% 50% at 50% -10%, rgba(251,191,36,0.08) 0%, transparent 60%), radial-gradient(ellipse 60% 40% at 80% 60%, rgba(245,158,11,0.035) 0%, transparent 60%), #08080a', + 'linear-gradient(180deg, rgba(14,22,28,0.8) 0%, rgba(8,8,10,0) 42%), #08080a', }} > {/* 1. Hero Section */} diff --git a/app/problems/[id]/page.tsx b/app/problems/[id]/page.tsx index f6dd93a..83e7817 100644 --- a/app/problems/[id]/page.tsx +++ b/app/problems/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { @@ -18,7 +18,6 @@ import { } from 'lucide-react'; import EditorWorkspace from '@/components/editor/EditorWorkspace'; import ProgressiveHints from '@/components/guidance/ProgressiveHints'; -import TutorDrawer from '@/components/guidance/TutorDrawer'; import { ProblemVisualizer } from '@/components/problems/ProblemVisualizer'; import { DifficultyBadge } from '@/components/ui/DifficultyBadge'; import { Problem, Submission } from '@/lib/types'; @@ -37,7 +36,8 @@ export default function ProblemDetailPage() { const [submissions, setSubmissions] = useState([]); const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(false); - const [isTutorOpen, setIsTutorOpen] = useState(false); + const [leftPanelWidth, setLeftPanelWidth] = useState(43); + const workspaceRef = useRef(null); const [copiedIndex, setCopiedIndex] = useState(null); @@ -106,6 +106,41 @@ export default function ProblemDetailPage() { setTimeout(() => setCopiedIndex(null), 2000); }; + const handleMainResizeStart = (event: React.PointerEvent) => { + if (!workspaceRef.current || window.innerWidth < 1024) return; + + const workspace = workspaceRef.current; + event.currentTarget.setPointerCapture(event.pointerId); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const handlePointerMove = (moveEvent: PointerEvent) => { + const rect = workspace.getBoundingClientRect(); + const rawPercentage = ((moveEvent.clientX - rect.left) / rect.width) * 100; + setLeftPanelWidth(Math.min(68, Math.max(28, rawPercentage))); + }; + + const stopResize = () => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', stopResize); + window.removeEventListener('pointercancel', stopResize); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', stopResize); + window.addEventListener('pointercancel', stopResize); + }; + + const handleMainResizeKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + + event.preventDefault(); + const direction = event.key === 'ArrowRight' ? 1 : -1; + setLeftPanelWidth((current) => Math.min(68, Math.max(28, current + direction * 2))); + }; + if (isLoading) { return (
@@ -171,9 +206,13 @@ export default function ProblemDetailPage() {
{/* Split Workspace View */} -
+
{/* LEFT PANEL: Problem Details & Tabs (5 Columns on Large Screens) */} -
+
{/* Navigation Tabs */}
)}
+
+ +
+ {/* RIGHT PANEL: Code Editor & Execution Workspace (7 Columns on Large Screens) */} -
+
- - {/* Floating tutor drawer */} - setIsTutorOpen(!isTutorOpen)} - />
); } diff --git a/app/revision/page.tsx b/app/revision/page.tsx index 94f9f3c..983273a 100644 --- a/app/revision/page.tsx +++ b/app/revision/page.tsx @@ -19,57 +19,44 @@ import { } from 'lucide-react'; import { useToast } from '@/components/ui/Toast'; import { DifficultyBadge } from '@/components/ui/DifficultyBadge'; +import { ProblemMarkdown } from '@/components/ui/ProblemMarkdown'; +import type { RevisionCardDTO, RevisionDeckResponse, RevisionDeckStats } from '@/lib/types'; +import { nextRevisionInterval } from '@/lib/revisionSchedule'; -interface ProblemData { - id: string; - title: string; - slug: string; - difficulty: string; - topicTags: string; - statement: string; - editorial: string; -} - -interface RevisionCardItem { - id: string; - problemId: string; - pattern: string; - keyTakeaway: string; - timeComplexity: string; - spaceComplexity: string; - interval: number; - repetitions: number; - dueDate: string; - failureCount: number; - lastFailureType?: string | null; - lastError?: string | null; - lastFailedInput?: string | null; - lastExpectedOutput?: string | null; - lastActualOutput?: string | null; - problem: ProblemData; -} +const EMPTY_STATS: RevisionDeckStats = { + totalCards: 0, + dueTodayCount: 0, + masteredCount: 0, + learnedMistakeCount: 0, + nextDueDate: null, +}; export default function RevisionPage() { - const [cards, setCards] = useState([]); - const [dueCards, setDueCards] = useState([]); - const [stats, setStats] = useState({ totalCards: 0, dueTodayCount: 0, masteredCount: 0, learnedMistakeCount: 0 }); + const [cards, setCards] = useState([]); + const [dueCards, setDueCards] = useState([]); + const [stats, setStats] = useState(EMPTY_STATS); const [currentIdx, setCurrentIdx] = useState(0); const [isFlipped, setIsFlipped] = useState(false); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const [submitting, setSubmitting] = useState(false); + const [selectedQuality, setSelectedQuality] = useState<'HARD' | 'GOOD' | 'EASY' | null>(null); const { showToast } = useToast(); + const activeDueCardId = dueCards[currentIdx]?.id; const fetchRevisionData = async (signal?: AbortSignal) => { try { const res = await fetch('/api/revision', { signal }); if (!res.ok) throw new Error('Failed to fetch revision deck'); - const data = await res.json(); + const data = (await res.json()) as RevisionDeckResponse; setCards(data.cards || []); setDueCards(data.dueCards || []); - setStats(data.stats || { totalCards: 0, dueTodayCount: 0, masteredCount: 0, learnedMistakeCount: 0 }); + setStats(data.stats || EMPTY_STATS); + setLoadError(null); } catch (err: unknown) { if (!(err instanceof DOMException && err.name === 'AbortError')) { console.error('Error fetching revision data:', err); + setLoadError('Could not load your revision deck. Please try again.'); } } finally { if (!signal?.aborted) setLoading(false); @@ -83,11 +70,20 @@ export default function RevisionPage() { return () => controller.abort(); }, []); + useEffect(() => { + if (currentIdx >= dueCards.length) setCurrentIdx(0); + }, [currentIdx, dueCards.length]); + + useEffect(() => { + setIsFlipped(false); + }, [activeDueCardId]); + const handleRateCard = async (quality: 'HARD' | 'GOOD' | 'EASY') => { const card = dueCards[currentIdx]; - if (!card) return; + if (!card || submitting) return; setSubmitting(true); + setSelectedQuality(quality); try { const res = await fetch('/api/revision', { method: 'POST', @@ -101,12 +97,14 @@ export default function RevisionPage() { if (res.ok) { showToast(`Card rated ${quality}! Next review scheduled.`, 'success'); setIsFlipped(false); - if (currentIdx < dueCards.length - 1) { - setCurrentIdx((prev) => prev + 1); - } else { - await fetchRevisionData(); - setCurrentIdx(0); - } + const remainingDueCards = dueCards.filter((dueCard) => dueCard.id !== card.id); + setDueCards(remainingDueCards); + setStats((previous) => ({ + ...previous, + dueTodayCount: Math.max(0, previous.dueTodayCount - 1), + })); + setCurrentIdx((previous) => Math.min(previous, Math.max(remainingDueCards.length - 1, 0))); + fetchRevisionData(); } else { const data = await res.json().catch(() => null); showToast(data?.error || 'Failed to record rating', 'error'); @@ -115,6 +113,7 @@ export default function RevisionPage() { showToast('Failed to record rating', 'error'); } finally { setSubmitting(false); + setSelectedQuality(null); } }; @@ -128,6 +127,10 @@ export default function RevisionPage() { } const currentCard = dueCards[currentIdx]; + const hasAnyCards = cards.length > 0; + const nextDueLabel = stats.nextDueDate + ? new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(new Date(stats.nextDueDate)) + : null; return (
@@ -148,7 +151,7 @@ export default function RevisionPage() {
{/* Stats Pill Strip */} -
+
@@ -181,7 +184,27 @@ export default function RevisionPage() {
{/* Main Flashcard Interactive Arena */} - {dueCards.length > 0 && currentCard ? ( + {loadError ? ( +
+
+ +
+

Revision Deck Could Not Load

+

+ Your learned mistakes are still saved, but this view could not fetch them right now. +

+ +
+ ) : dueCards.length > 0 && currentCard ? (
{/* Progress Bar */}
@@ -234,9 +257,11 @@ export default function RevisionPage() {

This card was brought forward because the platform found a weak spot in your latest submission.

)} -

- {currentCard.problem?.statement} -

+
@@ -321,7 +346,10 @@ export default function RevisionPage() { disabled={submitting} className="py-3 px-4 rounded-lg bg-rose-500/10 border border-rose-500/30 hover:bg-rose-500/20 text-rose-400 font-bold text-xs transition flex flex-col items-center gap-0.5" > - + + {selectedQuality === 'HARD' ? : Review in 1 Day
) : ( - /* Deck Completed View */ + /* Deck Empty or Completed View */
- + {hasAnyCards ? : }
-

All Revisions Completed Today

+

+ {hasAnyCards ? 'All Revisions Completed Today' : 'No Learned Mistakes Yet'} +

- Great job! You have revised all due DSA flashcards for today. Spaced repetition ensures long-term memory retention for FAANG interviews. + {hasAnyCards + ? `Great job! You have revised all due DSA flashcards for today.${nextDueLabel ? ` Next review: ${nextDueLabel}.` : ' The next cards will return when their memory interval is due.'}` + : 'Submit code or run sample cases while signed in. When the platform catches a wrong answer, runtime error, timeout, or compile error, it will turn that stuck point into a due revision card.'}

{ const ids = entries.map((entry) => entry.problemId).join(','); @@ -76,36 +77,145 @@ export default function VisualizerLibraryPage() { const selectedTitle = selected ? (problemMeta[selected.problemId]?.title || titleFromPath(selected.lessonPath)) : 'Choose a lesson'; const patterns = Array.from(new Set(entries.map((entry) => entry.pattern))); + const selectLesson = (problemId: string) => { + setSelectedId(problemId); + setMenuOpen(false); + }; + useEffect(() => { if (selected && !filteredEntries.some((entry) => entry.problemId === selected.problemId)) { setSelectedId(filteredEntries[0]?.problemId || ''); } }, [filteredEntries, selected]); + useEffect(() => { + if (!menuOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setMenuOpen(false); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [menuOpen]); + + const catalogPanel = ( +
+
+
+
Question Menu
+

{filteredEntries.length} lessons found

+
+ +
+ +
+
+ + + +
+ {filteredEntries.map((entry) => { + const title = problemMeta[entry.problemId]?.title || titleFromPath(entry.lessonPath); + const isSelected = entry.problemId === selected?.problemId; + return ( + + ); + })} + {filteredEntries.length === 0 && ( +
+ No lessons match this search. +
+ )} +
+
+ ); + return ( -
-
-
-
-
$ visualizer --catalog=75
-

Watch the algorithm think_

-

Interactive visual lessons for every mapped problem. Choose a pattern, step through its state, and inspect the invariant.

+
+
+
+
+ +
+
$ visualizer --catalog=75
+

Watch the algorithm think_

+
+
+
+
{entries.length || 75} verified lessons
+ {selected && ( + + Open workspace + + )}
-
{entries.length || 75} verified lessons
-
- - -
- {selected ? :
No lessons match this filter.
} - {selected &&
Interactive lesson: {selected.lessonPath}Open coding workspace
} -
-
+
+ {selected ? :
No lessons match this filter.
} +
+
+ +
+
); diff --git a/capacitor-www/index.html b/capacitor-www/index.html new file mode 100644 index 0000000..e6ac788 --- /dev/null +++ b/capacitor-www/index.html @@ -0,0 +1,47 @@ + + + + + + CodeForge Revision + + + +
+

CodeForge Revision

+

Connect to the CodeForge app server to load your revision deck.

+
+ + diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 0000000..56aa208 --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,18 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const serverUrl = process.env.CAPACITOR_SERVER_URL || 'http://10.0.2.2:3000/mobile'; + +const config: CapacitorConfig = { + appId: 'com.codeforge.revision', + appName: 'CodeForge Revision', + webDir: 'capacitor-www', + server: { + url: serverUrl, + cleartext: serverUrl.startsWith('http://'), + }, + android: { + allowMixedContent: true, + }, +}; + +export default config; diff --git a/components/contests/LiveCommentator.tsx b/components/contests/LiveCommentator.tsx index f7d9d16..1bb3b8a 100644 --- a/components/contests/LiveCommentator.tsx +++ b/components/contests/LiveCommentator.tsx @@ -1,8 +1,8 @@ 'use client'; -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Sparkles, Volume2, VolumeX, RefreshCw, Radio, Flame, Zap, ShieldAlert, History, UserCheck } from 'lucide-react'; +import { Sparkles, Volume2, VolumeX, RefreshCw, Radio, Flame, Zap, ShieldAlert, History } from 'lucide-react'; export interface ParticipantInfo { id?: string; @@ -25,6 +25,21 @@ export interface ExecutionResultInfo { [key: string]: unknown; } +export interface CodeAnalysisInfo { + lineCount: number; + nonEmptyLineCount: number; + functionCount: number; + loopCount: number; + conditionalCount: number; + hasInputParsing: boolean; + hasReturn: boolean; + hasConsoleOutput: boolean; + hasTodo: boolean; + possibleIssue: string; + phase: string; + signature: string; +} + export interface LiveCommentatorProps { roomCode?: string; roomName?: string; @@ -38,6 +53,8 @@ export interface LiveCommentatorProps { language?: string; codeSnippet?: string; linesOfCode?: number; + timeRemainingSeconds?: number; + privacyMode?: boolean; executionResult?: ExecutionResultInfo; userName?: string; className?: string; @@ -58,6 +75,81 @@ function stripEmojis(text: string): string { return text.replace(/\s+/g, ' ').trim(); } +function normalizeCommentaryText(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + + const normalized = value + .replace(/\s+/g, ' ') + .trim() + .slice(0, 220); + + return normalized.length >= 8 ? normalized : fallback; +} + +function normalizeHypeLevel(value: unknown): CommentaryMessage['hypeLevel'] { + return value === 'high' || value === 'medium' || value === 'low' ? value : 'medium'; +} + +function cheapHash(value: string): string { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; + } + return Math.abs(hash).toString(36); +} + +function analyzeCodeSnapshot(code: string, language: string): CodeAnalysisInfo { + const normalizedCode = typeof code === 'string' ? code : ''; + const lines = normalizedCode.split(/\r?\n/); + const nonEmptyLines = lines.filter((line) => line.trim().length > 0); + const withoutStrings = normalizedCode + .replace(/(["'`])(?:\\.|(?!\1)[\s\S])*\1/g, '""') + .replace(/\/\/.*$/gm, '') + .replace(/\/\*[\s\S]*?\*\//g, ''); + const lowerLanguage = language.toLowerCase(); + const functionMatches = withoutStrings.match(/\b(function|def|func)\b|(?:^|\s)[A-Za-z_][\w:<>,\s*&]*\s+[A-Za-z_]\w*\s*\([^;{}]*\)\s*\{/g) || []; + const loopMatches = withoutStrings.match(/\b(for|while|do)\b/g) || []; + const conditionalMatches = withoutStrings.match(/\b(if|else\s+if|switch|case)\b/g) || []; + const hasInputParsing = /\b(input|cin|scanf|readLine|readline|Scanner|BufferedReader|fmt\.Scan|process\.stdin)\b/.test(withoutStrings); + const hasReturn = /\breturn\b/.test(withoutStrings); + const hasConsoleOutput = /\b(console\.log|cout|printf|System\.out|print|fmt\.Print)\b/.test(withoutStrings); + const hasTodo = /\b(TODO|FIXME|placeholder|write your|implement)\b/i.test(normalizedCode); + + let phase = 'drafting'; + if (nonEmptyLines.length >= 35 || (loopMatches.length > 0 && conditionalMatches.length > 1 && hasReturn)) { + phase = 'implementation'; + } + if (hasConsoleOutput || hasReturn) { + phase = nonEmptyLines.length >= 8 ? 'testing-ready' : phase; + } + + let possibleIssue = 'No obvious issue spotted'; + if (nonEmptyLines.length === 0 || hasTodo) { + possibleIssue = 'Solution still looks like a placeholder'; + } else if ((lowerLanguage === 'cpp' || lowerLanguage === 'java' || lowerLanguage === 'go') && !hasReturn && !hasConsoleOutput) { + possibleIssue = 'No return or output path detected yet'; + } else if (nonEmptyLines.length > 10 && loopMatches.length === 0 && conditionalMatches.length === 0) { + possibleIssue = 'Core branching or iteration is not visible yet'; + } else if (nonEmptyLines.length > 20 && !hasInputParsing && ['cpp', 'java', 'go', 'javascript'].includes(lowerLanguage)) { + possibleIssue = 'Input parsing is not visible yet'; + } + + return { + lineCount: Math.min(lines.length, 100_000), + nonEmptyLineCount: Math.min(nonEmptyLines.length, 100_000), + functionCount: Math.min(functionMatches.length, 10_000), + loopCount: Math.min(loopMatches.length, 10_000), + conditionalCount: Math.min(conditionalMatches.length, 10_000), + hasInputParsing, + hasReturn, + hasConsoleOutput, + hasTodo, + possibleIssue, + phase, + signature: `${nonEmptyLines.length}:${functionMatches.length}:${loopMatches.length}:${conditionalMatches.length}:${cheapHash(normalizedCode.slice(-2000))}`, + }; +} + export const LiveCommentator: React.FC = ({ roomCode, roomName = 'Battle Arena', @@ -71,6 +163,8 @@ export const LiveCommentator: React.FC = ({ language = 'cpp', codeSnippet = '', linesOfCode = 0, + timeRemainingSeconds, + privacyMode = false, executionResult, userName, className = '', @@ -87,23 +181,49 @@ export const LiveCommentator: React.FC = ({ const [isMuted, setIsMuted] = useState(true); const [showHistory, setShowHistory] = useState(false); const [selectedVoice, setSelectedVoice] = useState(null); + const [analysisBeat, setAnalysisBeat] = useState(0); - const codeSnippetRef = useRef(codeSnippet); const participantsRef = useRef(participants); const executionResultRef = useRef(executionResult); const linesOfCodeRef = useRef(linesOfCode); const userNameRef = useRef(userName); + const timeRemainingSecondsRef = useRef(timeRemainingSeconds); + const codeAnalysisRef = useRef(analyzeCodeSnapshot(codeSnippet, language)); + const requestIdRef = useRef(0); + const abortControllerRef = useRef(null); + const lastRequestSignatureRef = useRef(''); + const analysisBeatRef = useRef(0); useEffect(() => { - codeSnippetRef.current = codeSnippet; participantsRef.current = participants; executionResultRef.current = executionResult; linesOfCodeRef.current = linesOfCode; userNameRef.current = userName; - }, [codeSnippet, participants, executionResult, linesOfCode, userName]); + timeRemainingSecondsRef.current = timeRemainingSeconds; + }, [participants, executionResult, linesOfCode, userName, timeRemainingSeconds]); const effectiveProblemTitle = problemTitle || activeProblemTitle || 'DSA Challenge'; const effectiveEvent = eventType || lastEvent || 'TICK'; const effectiveRoomCode = roomCode || roomName || 'ARENA-1'; + const participantScoreSignature = useMemo( + () => + participants + .map((participant) => `${participant.id || participant.userId || participant.name || participant.userName}:${participant.score ?? participant.scores ?? 0}:${participant.solved ?? 0}:${participant.progress || ''}`) + .join('|'), + [participants] + ); + const typingMilestone = Math.floor(Math.max(0, linesOfCode) / 10); + const executionSignature = `${executionResult?.verdict || ''}:${executionResult?.stderr || ''}`; + const clockBucket = timeRemainingSeconds === undefined ? 'na' : Math.floor(Math.max(0, timeRemainingSeconds) / 30); + const codeAnalysis = useMemo(() => analyzeCodeSnapshot(codeSnippet, language), [codeSnippet, language]); + const codeAnalysisSignature = codeAnalysis.signature; + + useEffect(() => { + codeAnalysisRef.current = codeAnalysis; + }, [codeAnalysis]); + + useEffect(() => { + analysisBeatRef.current = analysisBeat; + }, [analysisBeat]); // Voice Selection setup const loadBestVoice = useCallback(() => { @@ -183,14 +303,34 @@ export const LiveCommentator: React.FC = ({ // Fetch commentary from API route const fetchCommentary = useCallback( async (evtOverride?: string) => { - setLoading(true); const targetEvent = evtOverride || effectiveEvent; + const stateSignature = [ + targetEvent, + effectiveProblemTitle, + participantScoreSignature, + typingMilestone, + executionSignature, + targetEvent === 'CODE_ANALYSIS' ? codeAnalysisRef.current.signature : 'analysis', + targetEvent === 'TICK' ? clockBucket : 'event', + targetEvent === 'CODE_ANALYSIS' ? analysisBeatRef.current : 'beat', + ].join('::'); + + if (stateSignature === lastRequestSignatureRef.current && targetEvent !== 'MANUAL_REFRESH') return; + lastRequestSignatureRef.current = stateSignature; + setLoading(true); + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; try { const res = await fetch('/api/ai/commentator', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + signal: controller.signal, body: JSON.stringify({ roomCode: effectiveRoomCode, roomName, @@ -200,49 +340,84 @@ export const LiveCommentator: React.FC = ({ problemTitle: effectiveProblemTitle, activeProblemTitle: effectiveProblemTitle, language, - codeSnippet: codeSnippetRef.current, linesOfCode: linesOfCodeRef.current, + timeRemainingSeconds: timeRemainingSecondsRef.current, + codeAnalysis: codeAnalysisRef.current, executionResult: executionResultRef.current, userName: userNameRef.current, mode, status, + privacyMode: privacyMode ? 'private_room' : false, }), }); + if (requestId !== requestIdRef.current) return; + if (res.ok) { const data = await res.json(); - let text = ' Battle action intensifying in the arena!'; - let hype: 'high' | 'medium' | 'low' = 'medium'; + let text = 'Battle action intensifying in the arena!'; + let hype: CommentaryMessage['hypeLevel'] = 'medium'; let ts = Date.now(); - let spk: 'Shoutcaster' = 'Shoutcaster'; if (data.commentary) { if (typeof data.commentary === 'string') { - text = data.commentary; + text = normalizeCommentaryText(data.commentary, text); } else if (typeof data.commentary === 'object') { - text = data.commentary.text || data.commentary.commentary || text; + text = normalizeCommentaryText(data.commentary.text || data.commentary.commentary, text); } } - if (data.hypeLevel) hype = data.hypeLevel; - if (data.timestamp) ts = data.timestamp; - if (data.speaker) spk = data.speaker; + hype = normalizeHypeLevel(data.hypeLevel); + if (Number.isFinite(data.timestamp)) ts = data.timestamp; const msgObj: CommentaryMessage = { id: `comm_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`, text, timestamp: ts, hypeLevel: hype, - speaker: spk, + speaker: 'Shoutcaster', }; + setCurrentMessage(msgObj); + setHistory((prev) => [msgObj, ...prev.slice(0, 7)]); + speakCommentary(text); + } else { + const analysis = codeAnalysisRef.current; + const text = targetEvent === 'CODE_ANALYSIS' + ? `CODE READ: ${userNameRef.current || 'Coder'} has ${analysis.nonEmptyLineCount} active lines; ${analysis.possibleIssue.toLowerCase()}.` + : 'Battle action continues while the commentator reconnects.'; + const msgObj: CommentaryMessage = { + id: `comm_local_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`, + text, + timestamp: Date.now(), + hypeLevel: targetEvent === 'CODE_ANALYSIS' && analysis.possibleIssue !== 'No obvious issue spotted' ? 'medium' : 'low', + speaker: 'Shoutcaster', + }; setCurrentMessage(msgObj); setHistory((prev) => [msgObj, ...prev.slice(0, 7)]); speakCommentary(text); } } catch (err) { - console.error('[LiveCommentator Error]:', err); + if (!(err instanceof DOMException && err.name === 'AbortError')) { + console.error('[LiveCommentator Error]:', err); + const analysis = codeAnalysisRef.current; + const text = targetEvent === 'CODE_ANALYSIS' + ? `CODE READ: ${userNameRef.current || 'Coder'} is in ${analysis.phase} with ${analysis.nonEmptyLineCount} active lines.` + : 'Battle action continues while the commentator reconnects.'; + const msgObj: CommentaryMessage = { + id: `comm_recover_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`, + text, + timestamp: Date.now(), + hypeLevel: 'low', + speaker: 'Shoutcaster', + }; + setCurrentMessage(msgObj); + setHistory((prev) => [msgObj, ...prev.slice(0, 7)]); + speakCommentary(text); + } } finally { - setLoading(false); + if (requestId === requestIdRef.current) { + setLoading(false); + } } }, [ @@ -253,14 +428,48 @@ export const LiveCommentator: React.FC = ({ language, mode, status, + privacyMode, speakCommentary, + participantScoreSignature, + typingMilestone, + executionSignature, + clockBucket, ] ); // Trigger fetch on event or prop changes useEffect(() => { fetchCommentary(effectiveEvent); - }, [fetchCommentary, effectiveEvent, effectiveProblemTitle, participants.length]); + }, [ + fetchCommentary, + effectiveEvent, + effectiveProblemTitle, + participantScoreSignature, + typingMilestone, + executionSignature, + clockBucket, + ]); + + useEffect(() => { + if (status !== 'IN_PROGRESS') return; + const interval = setInterval(() => { + setAnalysisBeat((beat) => beat + 1); + }, 5000); + + return () => clearInterval(interval); + }, [status]); + + useEffect(() => { + if (status !== 'IN_PROGRESS') return; + if (codeAnalysis.nonEmptyLineCount === 0) return; + fetchCommentary('CODE_ANALYSIS'); + }, [ + analysisBeat, + codeAnalysisSignature, + codeAnalysis.nonEmptyLineCount, + fetchCommentary, + status, + ]); // Periodic interval tick useEffect(() => { @@ -271,6 +480,10 @@ export const LiveCommentator: React.FC = ({ return () => clearInterval(interval); }, [fetchCommentary]); + useEffect(() => { + return () => abortControllerRef.current?.abort(); + }, []); + const toggleTTS = () => { const nextMute = !isMuted; setIsMuted(nextMute); @@ -310,38 +523,39 @@ export const LiveCommentator: React.FC = ({ return (
- {/* Dynamic Background Glow */} -
- {/* Header Bar */} -
-
-
+
+
+
Live Shoutcaster
- - {/* Hype Level Badge */} - - {hypeProps.icon} - {hypeProps.label} - +
+ + {hypeProps.icon} + {hypeProps.label} + + {privacyMode && ( + + + PRIVATE + + )} +
{/* Action Button Controls */} -
+
{/* Recent History Toggle */} @@ -379,7 +591,7 @@ export const LiveCommentator: React.FC = ({ disabled={loading} aria-label="Refresh live commentary" title="Trigger Fresh Callout" - className="p-1.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-400 hover:text-white transition disabled:opacity-50" + className="p-1.5 rounded-lg bg-slate-950 border border-slate-800 text-slate-400 hover:text-white transition disabled:opacity-50" > @@ -397,17 +609,17 @@ export const LiveCommentator: React.FC = ({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -8, scale: 0.98 }} transition={{ duration: 0.25 }} - className="text-xs sm:text-sm font-semibold text-slate-100 leading-relaxed bg-slate-950/80 border border-slate-800/90 rounded-xl p-3.5 shadow-inner flex items-start gap-3 relative" + className="text-xs font-semibold text-slate-100 leading-relaxed bg-slate-950/70 border border-slate-800 rounded-lg p-3 flex items-start gap-2.5 relative" > -
- +
+
SPEAKER: {currentMessage.speaker} {new Date(currentMessage.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
-

{currentMessage.text}

+

{currentMessage.text}

diff --git a/components/dashboard/SolvedSummaryCards.tsx b/components/dashboard/SolvedSummaryCards.tsx index 3d3aba3..a7cb8bc 100644 --- a/components/dashboard/SolvedSummaryCards.tsx +++ b/components/dashboard/SolvedSummaryCards.tsx @@ -33,9 +33,9 @@ export function SolvedSummaryCards({ user, solved, accuracy, avgTime }: SolvedSu const totalSystemProblems = solved.totalEasy + solved.totalMedium + solved.totalHard; return ( -
+
{/* 1. Rating & Tier Badge Card */} -
+
Contest Rating @@ -62,7 +62,7 @@ export function SolvedSummaryCards({ user, solved, accuracy, avgTime }: SolvedSu
{/* 2. Total Solved Progress Card */} -
+
Problems Solved @@ -87,7 +87,7 @@ export function SolvedSummaryCards({ user, solved, accuracy, avgTime }: SolvedSu title={`Easy: ${solved.easy}`} />
@@ -107,7 +107,7 @@ export function SolvedSummaryCards({ user, solved, accuracy, avgTime }: SolvedSu
{/* 3. Difficulty Breakdown Card */} -
+
Difficulty Breakdown @@ -118,9 +118,9 @@ export function SolvedSummaryCards({ user, solved, accuracy, avgTime }: SolvedSu
{solved.easy}
/{solved.totalEasy}
-
-
Med
-
{solved.medium}
+
+
Med
+
{solved.medium}
/{solved.totalMedium}
@@ -132,16 +132,16 @@ export function SolvedSummaryCards({ user, solved, accuracy, avgTime }: SolvedSu
{/* 4. Accuracy & Efficiency Card */} -
+
Accuracy & Speed - +
- + {accuracy}% AC Rate diff --git a/components/dashboard/TeachingStyleSelector.tsx b/components/dashboard/TeachingStyleSelector.tsx index bf4ef78..e48c723 100644 --- a/components/dashboard/TeachingStyleSelector.tsx +++ b/components/dashboard/TeachingStyleSelector.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState } from 'react'; +import Image from 'next/image'; import { motion, AnimatePresence } from 'framer-motion'; import { Sparkles, Check, ChevronDown, ChevronUp, Bot } from 'lucide-react'; import { useTeachingStyle } from '@/hooks/useTeachingStyle'; @@ -12,6 +13,35 @@ interface PersonalityCardProps { onSelect: (id: TeachingStyleId) => void; } +function PersonalityAvatar({ + personality, + size = 'md', +}: { + personality: TeachingStyle; + size?: 'sm' | 'md' | 'lg'; +}) { + const sizeClass = size === 'sm' ? 'w-5 h-5' : size === 'lg' ? 'w-11 h-11' : 'w-10 h-10'; + const imageSize = size === 'sm' ? 20 : size === 'lg' ? 44 : 40; + const iconClass = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'; + + return ( +
+ {personality.avatar ? ( + + ) : ( +
+ ); +} + function PersonalityCard({ personality, isSelected, onSelect }: PersonalityCardProps) { return ( - {/* Semantic teaching-style avatar */} -
-
+ {/* Info */}
@@ -88,16 +112,16 @@ export function TeachingStyleSelector() {
{/* Section Header */}
-
- +
+

Teaching Style

Shapes how your tutor, hints, and reviews speak to you

- -
@@ -112,12 +136,10 @@ export function TeachingStyleSelector() { whileTap={{ scale: 0.99 }} className={` w-full flex items-center gap-3 rounded-xl border p-3.5 transition-all duration-300 - bg-[#17171b] border-amber-400/40 shadow-md shadow-amber-400/5 + bg-[#17171b] border-amber-300/25 `} > -
-
+
{personality.name} @@ -147,9 +169,9 @@ export function TeachingStyleSelector() { >
{/* Info banner */} -
- -

+

+ +

Your tutor, hints, code reviews, mock interviews, and recommendations will all speak in this personality's unique voice.

diff --git a/components/dashboard/WeeklyInsights.tsx b/components/dashboard/WeeklyInsights.tsx index efb9d5d..9822ec1 100644 --- a/components/dashboard/WeeklyInsights.tsx +++ b/components/dashboard/WeeklyInsights.tsx @@ -55,17 +55,17 @@ export function WeeklyInsights() { }, []); return ( -
+
-
- +
+

Weekly Progress Insights

-

+

Powered by FreeModel

@@ -85,7 +85,7 @@ export function WeeklyInsights() { {loading && (
- +

Analyzing submission patterns and rating trajectory...

)} @@ -99,13 +99,13 @@ export function WeeklyInsights() { {report && !loading && (
{/* Executive Summary */} -
+
"{report.summary}"
{/* Strengths */} -
+
Key Strengths @@ -118,8 +118,8 @@ export function WeeklyInsights() {
{/* Focus Areas */} -
-
+
+
Focus Areas
@@ -131,9 +131,9 @@ export function WeeklyInsights() {
{/* Recommendations & Estimated Gain */} -
+
-
+
Recommendations
@@ -144,7 +144,7 @@ export function WeeklyInsights() {
-
+
Potential Rating Gain: +{report.estimatedRatingGain || 45} pts diff --git a/components/editor/EditorWorkspace.tsx b/components/editor/EditorWorkspace.tsx index 602a3b2..1e0fec1 100644 --- a/components/editor/EditorWorkspace.tsx +++ b/components/editor/EditorWorkspace.tsx @@ -18,6 +18,7 @@ import { } from 'lucide-react'; import { ExecuteApiResponse, SubmissionApiResponse, TestCaseResult } from '@/lib/types'; import CodeReviewModal from '@/components/guidance/CodeReviewModal'; +import TutorDrawer from '@/components/guidance/TutorDrawer'; import { useAuth } from '@/context/AuthContext'; interface CodeTemplateItem { @@ -72,8 +73,12 @@ export const EditorWorkspace: React.FC = ({ const [executeResult, setExecuteResult] = useState(null); const [submissionResult, setSubmissionResult] = useState(null); const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [isTutorOpen, setIsTutorOpen] = useState(false); + const [bottomPanelHeight, setBottomPanelHeight] = useState(256); + const [tutorWidth, setTutorWidth] = useState(400); const codeRef = useRef(''); const appliedTemplateRef = useRef<{ language: string; code: string } | null>(null); + const workspaceRef = useRef(null); // Load a template when entering a language, but never overwrite edits when // the parent refreshes problem data and creates a new templates array. @@ -236,8 +241,82 @@ export const EditorWorkspace: React.FC = ({ const currentVerdict = submissionResult?.verdict || executeResult?.verdict; + const handleBottomResizeStart = (event: React.PointerEvent) => { + if (!workspaceRef.current) return; + + const workspace = workspaceRef.current; + event.currentTarget.setPointerCapture(event.pointerId); + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + + const handlePointerMove = (moveEvent: PointerEvent) => { + const rect = workspace.getBoundingClientRect(); + const nextHeight = rect.bottom - moveEvent.clientY; + const maxHeight = Math.max(160, rect.height - 260); + setBottomPanelHeight(Math.min(maxHeight, Math.max(140, nextHeight))); + }; + + const stopResize = () => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', stopResize); + window.removeEventListener('pointercancel', stopResize); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', stopResize); + window.addEventListener('pointercancel', stopResize); + }; + + const handleBottomResizeKeyDown = (event: React.KeyboardEvent) => { + if (!workspaceRef.current || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return; + + event.preventDefault(); + const maxHeight = Math.max(160, workspaceRef.current.getBoundingClientRect().height - 260); + const direction = event.key === 'ArrowUp' ? 1 : -1; + setBottomPanelHeight((current) => Math.min(maxHeight, Math.max(140, current + direction * 16))); + }; + + const handleTutorResizeStart = (event: React.PointerEvent) => { + if (!workspaceRef.current) return; + + event.currentTarget.setPointerCapture(event.pointerId); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (!workspaceRef.current) return; + const rect = workspaceRef.current.getBoundingClientRect(); + const nextWidth = rect.right - moveEvent.clientX; + const maxWidth = Math.max(250, rect.width - 300); // 300px min for editor + setTutorWidth(Math.min(maxWidth, Math.max(250, nextWidth))); + }; + + const stopResize = () => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', stopResize); + window.removeEventListener('pointercancel', stopResize); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', stopResize); + window.addEventListener('pointercancel', stopResize); + }; + + const handleTutorResizeKeyDown = (event: React.KeyboardEvent) => { + if (!workspaceRef.current || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return; + + event.preventDefault(); + const maxWidth = Math.max(250, workspaceRef.current.getBoundingClientRect().width - 300); + const direction = event.key === 'ArrowRight' ? -1 : 1; + setTutorWidth((current) => Math.min(maxWidth, Math.max(250, current + direction * 16))); + }; + return ( -
+
{/* Top Bar / Toolbar */}
{/* Language Selector */} @@ -309,21 +388,58 @@ export const EditorWorkspace: React.FC = ({
{/* Editor Body */} -
- +
+ { + codeRef.current = val; + setCode(val); + }} + onRun={handleRunCode} + onSubmit={handleSubmitCode} + /> +
+ {isTutorOpen && ( +
+ )} + { - codeRef.current = val; - setCode(val); - }} - onRun={handleRunCode} - onSubmit={handleSubmitCode} + isOpen={isTutorOpen} + width={tutorWidth} + onToggle={() => setIsTutorOpen((current) => !current)} />
+
+ {/* Bottom Panel (Test Cases & Results) */} -
+
{/* Panel Tabs Header */}
@@ -606,6 +722,16 @@ export const EditorWorkspace: React.FC = ({
)} + {executeResult.learning && ( +
+ +
+ Revision Deck learned this stuck point.{' '} + Your {executeResult.learning.failureType} on {executeResult.learning.pattern} is due for review now with the failing run details. +
+
+ )} + {executeResult.testResults && executeResult.testResults.length > 0 && (
diff --git a/components/footer/Footer.tsx b/components/footer/Footer.tsx index 956b414..ffc28c1 100644 --- a/components/footer/Footer.tsx +++ b/components/footer/Footer.tsx @@ -10,7 +10,7 @@ export const Footer: React.FC = () => { const pathname = usePathname(); // Hide footer inside full screen workspace editor routes - if (pathname.startsWith('/problems/') && pathname !== '/problems') { + if ((pathname.startsWith('/problems/') && pathname !== '/problems') || pathname === '/visualizer') { return null; } diff --git a/components/guidance/TutorDrawer.tsx b/components/guidance/TutorDrawer.tsx index 0af69ec..46c84f6 100644 --- a/components/guidance/TutorDrawer.tsx +++ b/components/guidance/TutorDrawer.tsx @@ -1,7 +1,8 @@ 'use client'; import React, { useState, useRef, useEffect } from 'react'; -import { Bot, X, Send, Sparkles, User, Loader2, MessageSquare, Zap } from 'lucide-react'; +import Image from 'next/image'; +import { Bot, X, Send, Sparkles, User, Loader2, Zap } from 'lucide-react'; import { TEACHING_STYLE_STORAGE_KEY, getTeachingStyle } from '@/lib/teachingStyles'; interface Message { @@ -12,11 +13,13 @@ interface Message { interface TutorDrawerProps { isOpen: boolean; onToggle: () => void; + variant?: 'fixed' | 'side-panel'; problemId?: string; problemTitle: string; problemStatement?: string; userCode: string; language: string; + width?: number; } const QUICK_PROMPTS = [ @@ -26,19 +29,44 @@ const QUICK_PROMPTS = [ 'Give me a hint without giving solution', ]; +function TutorAvatar({ avatar, name, size = 28 }: { avatar: string; name: string; size?: number }) { + return ( +
+ {avatar ? ( + + ) : ( + + )} +
+ ); +} + export const TutorDrawer: React.FC = ({ isOpen, onToggle, + variant = 'fixed', problemId, problemTitle, problemStatement = '', userCode, language, + width, }) => { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const messagesEndRef = useRef(null); + const isSidePanel = variant === 'side-panel'; // Read personality from localStorage const personality = getTeachingStyle( @@ -123,25 +151,32 @@ export const TutorDrawer: React.FC = ({ type="button" aria-label="Open DSA tutor" onClick={onToggle} - className="fixed bottom-6 right-6 z-40 flex items-center gap-2 px-4 py-3 bg-amber-400 hover:bg-amber-300 text-[#08080a] font-bold rounded-lg shadow-2xl shadow-amber-400/10 transition-all transform hover:scale-105" + className={`z-40 flex items-center gap-2 px-4 py-3 bg-amber-400 hover:bg-amber-300 text-[#08080a] font-bold rounded-lg shadow-2xl shadow-amber-400/10 transition-all transform hover:scale-105 ${ + isSidePanel ? 'absolute bottom-4 right-4' : 'fixed bottom-6 right-6' + }`} > - Socratic Tutor + DSA Tutor )} {/* Drawer Overlay Panel */} {isOpen && ( -
+
{/* Header */}
-
- -
+

- DSA Socratic Tutor + {personality.name} Tutor FreeModel @@ -183,8 +218,8 @@ export const TutorDrawer: React.FC = ({ className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`} > {msg.role === 'assistant' && ( -
- +
+
)} @@ -208,8 +243,8 @@ export const TutorDrawer: React.FC = ({ {isLoading && (
-
- +
+
diff --git a/components/landing/CoreFeaturesGrid.tsx b/components/landing/CoreFeaturesGrid.tsx index 0e740ca..7d74fa7 100644 --- a/components/landing/CoreFeaturesGrid.tsx +++ b/components/landing/CoreFeaturesGrid.tsx @@ -20,61 +20,61 @@ export const CoreFeaturesGrid: React.FC = () => { { title: 'Judge0 Execution Engine', desc: 'Secure containerized sandbox executing C++, Python, JavaScript, Java, and Go in milliseconds with detailed verdict output.', - icon: , + icon: , badge: 'Sandboxed Runner', link: '/problems', - color: 'hover:border-amber-400/50 hover:shadow-amber-950/20', + color: 'hover:border-amber-300/35', }, { title: 'Automated Code Audit', desc: 'Get instant analysis on time/space complexity, edge case vulnerabilities, and refactoring suggestions powered by FreeModel.', - icon: , + icon: , badge: 'FreeModel', link: '/problems', - color: 'hover:border-amber-400/50 hover:shadow-amber-950/20', + color: 'hover:border-violet-300/35', }, { title: '3-Level Progressive Hints', desc: 'Never get stuck again. Unlock hints incrementally: 1) High-level strategy, 2) Algorithmic breakdown, 3) Pseudocode structure.', - icon: , + icon: , badge: 'Zero Spoilers', link: '/problems', - color: 'hover:border-amber-400/50 hover:shadow-amber-950/20', + color: 'hover:border-emerald-300/35', }, { title: 'Codeforces Rated Contests', desc: 'Start an on-demand rated battle, invite friends, or face the AI judge with a focused problem set.', - icon: , + icon: , badge: 'Elo Rating System', link: '/contests', - color: 'hover:border-amber-400/50 hover:shadow-amber-950/20', + color: 'hover:border-sky-300/35', }, { title: 'Recharts Analytics Dashboard', desc: 'Visualize topic mastery with radar charts, track rating progression curves, and maintain your GitHub-style activity heatmaps.', - icon: , + icon: , badge: 'Visual Insights', link: '/dashboard', - color: 'hover:border-amber-400/50 hover:shadow-amber-950/20', + color: 'hover:border-teal-300/35', }, { title: 'Company Specific Prep', desc: 'Curated problem collections tagged by Google, Meta, Amazon, Apple, and Netflix, complete with frequency tags.', - icon: , + icon: , badge: '8 Tech Giants', link: '/company', - color: 'hover:border-rose-500/50 hover:shadow-rose-950/40', + color: 'hover:border-rose-300/35', }, ]; return (
-
- +
+ Complete Competitive Engineering Suite
-

+

Everything You Need to Master DSA

@@ -87,16 +87,16 @@ export const CoreFeaturesGrid: React.FC = () => { {features.map((item, idx) => (

-
+
{item.icon}
@@ -105,7 +105,7 @@ export const CoreFeaturesGrid: React.FC = () => {
-

+

{item.title}

@@ -117,7 +117,7 @@ export const CoreFeaturesGrid: React.FC = () => {

Learn More diff --git a/components/landing/HeroSection.tsx b/components/landing/HeroSection.tsx index 1911356..1cb881b 100644 --- a/components/landing/HeroSection.tsx +++ b/components/landing/HeroSection.tsx @@ -4,14 +4,13 @@ import React from 'react'; import Link from 'next/link'; import { motion } from 'framer-motion'; import { - Sparkles, ArrowRight, Bot, Code2, Building2, Zap, - Cpu, CheckCircle2, + Braces, } from 'lucide-react'; export const HeroSection: React.FC = () => { @@ -20,29 +19,29 @@ export const HeroSection: React.FC = () => { label: 'DSA Problems', value: '600+', sub: 'Arrays to Dynamic Programming', - icon: , - color: 'border-amber-400/30', + icon: , + color: 'border-amber-300/25', }, { label: 'Tech Companies', value: '8', sub: 'Google, Meta, Amazon, Apple...', - icon: , - color: 'border-amber-400/30', + icon: , + color: 'border-violet-300/25', }, { label: 'Judge0 Execution', value: 'CE', sub: 'Isolated Sandbox Code Runner', - icon: , - color: 'border-amber-400/30', + icon: , + color: 'border-emerald-300/25', }, { label: 'Guided Practice', - value: 'FreeModel', - sub: 'Instant Tutor & Hints', - icon: , - color: 'border-amber-400/30', + value: 'Gemini + FreeModel', + sub: 'Gemini first, FreeModel API key fallback', + icon: , + color: 'border-sky-300/25', }, ]; @@ -50,32 +49,32 @@ export const HeroSection: React.FC = () => {
{/* Feature Badge */} - + Focused Competitive Coding & Interview Platform - + {/* Gradient Headline */} Master Coding & Crack Tech Interviews with{' '} - + CodeForge {/* Subheading */} { {/* CTA Action Buttons */} Explore Problems @@ -100,33 +99,33 @@ export const HeroSection: React.FC = () => { - + Try Mock Interview {/* Stats Ticker Cards */} {stats.map((item) => (
- {item.icon} + {item.icon}
-
{item.value}
+
{item.value}
{item.label}
{item.sub}
diff --git a/components/navbar/Navbar.tsx b/components/navbar/Navbar.tsx index 984745a..4f5ed05 100644 --- a/components/navbar/Navbar.tsx +++ b/components/navbar/Navbar.tsx @@ -7,45 +7,67 @@ import { usePathname } from 'next/navigation'; import { motion, AnimatePresence } from 'framer-motion'; import { useAuth } from '@/context/AuthContext'; import { - Code2, - Trophy, - Building2, + BarChart3, Bot, - BarChart2, - Menu, - X, - Code, + Brain, + Building2, + ChevronDown, + Code2, Github, + LayoutDashboard, LogIn, - UserPlus, LogOut, - Brain, Sparkles, + Trophy, + UserPlus, + Menu, + X, } from 'lucide-react'; export const Navbar: React.FC = () => { const pathname = usePathname(); const { user, logout } = useAuth(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); const menuButtonRef = useRef(null); + const profileMenuRef = useRef(null); + const githubUrl = 'https://github.com/sparsh101sparsh/hackathon2'; useEffect(() => { setIsMobileMenuOpen(false); + setIsProfileMenuOpen(false); }, [pathname]); useEffect(() => { - if (!isMobileMenuOpen) return; + if (!isMobileMenuOpen && !isProfileMenuOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsMobileMenuOpen(false); + setIsProfileMenuOpen(false); menuButtonRef.current?.focus(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [isMobileMenuOpen]); + }, [isMobileMenuOpen, isProfileMenuOpen]); + + useEffect(() => { + if (!isProfileMenuOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if ( + profileMenuRef.current && + !profileMenuRef.current.contains(event.target as Node) + ) { + setIsProfileMenuOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + return () => document.removeEventListener('pointerdown', handlePointerDown); + }, [isProfileMenuOpen]); // Hide main navbar inside problem detail view if it uses full screen workspace layout if (pathname.startsWith('/problems/') && pathname !== '/problems') { @@ -53,41 +75,45 @@ export const Navbar: React.FC = () => { } const navLinks = [ - { label: 'Problems', href: '/problems', icon: }, - { label: 'Visualizer', href: '/visualizer', icon: }, - { label: 'Revision Deck', href: '/revision', icon: }, - { label: 'AI Mock Interview', href: '/mock-interview', icon: }, - { label: 'Company Prep', href: '/company', icon: }, - { label: 'Contests', href: '/contests', icon: }, - { label: 'Leaderboard', href: '/leaderboard', icon: }, + { label: 'Problems', href: '/problems', icon: